diff --git a/example/gm_run_exclusive.js b/example/gm_run_exclusive.js
new file mode 100644
index 000000000..3ffdf9456
--- /dev/null
+++ b/example/gm_run_exclusive.js
@@ -0,0 +1,185 @@
+// ==UserScript==
+// @name GM.runExclusive Demo
+// @namespace https://docs.scriptcat.org/
+// @version 0.1.2
+// @match https://example.com/*?runExclusive*
+// @grant GM.runExclusive
+// @grant GM.setValue
+// @grant GM.getValue
+// @run-at document-start
+// @allFrames
+// ==/UserScript==
+
+(async function () {
+ 'use strict';
+
+ const delayMatch = location.href.match(/runExclusive(\d+)_(\d*)/);
+ const timeDelay = delayMatch ? +delayMatch[1] : 0;
+ const timeoutValue = (delayMatch ? +delayMatch[2] : 0) || -1;
+ const isWorker = !!timeDelay;
+
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(`
+ #exclusive-test-panel {
+ all: unset;
+ }
+ #exclusive-test-panel div, #exclusive-test-panel p, #exclusive-test-panel span {
+ opacity: 1.0;
+ line-height: 1;
+ font-size: 10pt;
+ }
+ `);
+ document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
+
+ /* ---------- Shared UI helpers ---------- */
+ const panel = document.createElement('div');
+ panel.id = "exclusive-test-panel";
+ Object.assign(panel.style, {
+ opacity: "1.0",
+ position: 'fixed',
+ boxSizing: 'border-box',
+ top: '10px',
+ right: '10px',
+ background: '#3e3e3e',
+ color: '#e0e0e0',
+ padding: '14px',
+ borderRadius: '8px',
+ fontFamily: 'monospace',
+ zIndex: 99999,
+ width: '420px'
+ });
+ document.documentElement.appendChild(panel);
+
+ const logContainer = document.createElement('div');
+ panel.appendChild(logContainer);
+
+ const getTimeWithMilliseconds = date => `${date.toLocaleTimeString('it-US')}.${date.getMilliseconds()}`;
+
+ const log = (msg, color = '#ccc') => {
+ const line = document.createElement('div');
+ line.textContent = msg.startsWith(" ") ? msg : `[${getTimeWithMilliseconds(new Date())}] ${msg}`;
+ line.style.color = color;
+ logContainer.appendChild(line);
+ };
+
+ /* ======================================================
+ MAIN PAGE (Controller)
+ ====================================================== */
+ if (!isWorker) {
+ panel.style.width = "480px";
+ panel.innerHTML = `
+
GM.runExclusive Demo
+ Pick worker durations (ms):
+
+
+
+
+
+
+
+ `;
+
+ const iframeContainer = panel.querySelector('#iframeContainer');
+
+ panel.querySelector('#reset').onclick = async () => {
+ await GM.setValue('mValue01', 0);
+ await GM.setValue('order', 0);
+ iframeContainer.innerHTML = '';
+ log('Shared counters reset', '#ff0');
+ };
+
+ panel.querySelector('#run').onclick = async () => {
+ iframeContainer.innerHTML = '';
+ await GM.setValue('mValue01', 0);
+ await GM.setValue('order', 0);
+
+ const delays = panel
+ .querySelector('#durations')
+ .value.split(',')
+ .map(v => +v.trim())
+ .filter(Boolean);
+
+ let timeoutQ = +panel.querySelector("#timeout").value.trim() || "";
+
+ log(`Launching workers: ${delays.join(', ')}`, '#0f0');
+
+ delays.forEach(delay => {
+ const iframe = document.createElement('iframe');
+ iframe.src = `${location.pathname}?runExclusive${delay}_${timeoutQ}`;
+ iframe.style.width = '100%';
+ iframe.style.height = '160px';
+ iframe.style.border = '1px solid #444';
+ iframe.style.marginTop = '8px';
+ iframeContainer.appendChild(iframe);
+ });
+ };
+
+ window.addEventListener('message', (e) => {
+ if (e.data?.type !== 'close-worker') return;
+ const iframes = iframeContainer.querySelectorAll('iframe');
+ for (const iframe of iframes) {
+ if (iframe.src.includes(`runExclusive${e.data.delay}_`)) {
+ iframe.remove();
+ log(`Closed worker ${e.data.delay}ms`, '#ff9800');
+ return;
+ }
+ }
+ });
+
+ return;
+ }
+
+ /* ======================================================
+ WORKER IFRAME
+ ====================================================== */
+
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = 'Close';
+ Object.assign(closeBtn.style, {
+ margin: '8px',
+ padding: '4px 8px',
+ cursor: 'pointer',
+ position: 'absolute',
+ top: '0px',
+ right: '0px',
+ boxSizing: 'border-box'
+ });
+ closeBtn.onclick = () => {
+ window.parent.postMessage({ type: 'close-worker', delay: timeDelay }, '*');
+ };
+ panel.appendChild(closeBtn);
+
+ log(` [Worker] duration=${timeDelay}ms${timeoutValue > 0 ? " timeout=" + timeoutValue + "ms" : ""}`, '#fff');
+ log('Waiting for exclusive lock…', '#0af');
+
+ const startWait = performance.now();
+
+ try {
+ const result = await GM.runExclusive('demo-lock-key', async () => {
+ const waited = Math.round(performance.now() - startWait);
+
+ const order = (await GM.getValue('order')) + 1;
+ await GM.setValue('order', order);
+
+ log(`Lock acquired (#${order}, waited ${waited}ms)`, '#0f0');
+
+ const val = await GM.getValue('mValue01');
+ await GM.setValue('mValue01', val + timeDelay);
+
+ log(`Working ${timeDelay}ms…`, '#ff0');
+ await new Promise(r => setTimeout(r, timeDelay));
+
+ const final = await GM.getValue('mValue01');
+ log(`Done. Shared value = ${final}`, '#f55');
+
+ return { order, waited, final };
+ }, timeoutValue);
+ log(`Result: ${JSON.stringify(result)}`, '#fff');
+ } catch (e) {
+ log(`Error: ${JSON.stringify(e?.message || e)}`, '#f55');
+ }
+
+
+})();
diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts
index d82c817fc..5f50039cc 100644
--- a/src/app/service/content/gm_api/gm_api.ts
+++ b/src/app/service/content/gm_api/gm_api.ts
@@ -24,6 +24,7 @@ import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/messag
import { type TGMKeyValue } from "@App/app/repo/value";
import type { ContextType } from "./gm_xhr";
import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr";
+import { stackAsyncTask } from "@App/pkg/utils/async_queue";
// 内部函数呼叫定义
export interface IGM_Base {
@@ -1476,6 +1477,91 @@ export default class GMApi extends GM_Base {
public CAT_scriptLoaded() {
return this.loadScriptPromise;
}
+
+ @GMContext.API({ alias: "GM_runExclusive" })
+ ["GM.runExclusive"](lockKey: string, cb: () => T | PromiseLike, timeout: number = -1): Promise {
+ lockKey = `${lockKey}`; // 转化为字串
+ if (!lockKey || !this.scriptRes) {
+ throw new Error("GM.runExclusive: Invalid Calling");
+ }
+ const key = `${getStorageName(this.scriptRes).replace(/:/g, ":_")}::${lockKey.replace(/:/g, ":_")}`;
+
+ const taskAsync = () =>
+ new Promise((resolve, reject) => {
+ let killConn: (() => any) | null | undefined = undefined;
+ let error: any;
+ let result: any;
+ let state = 0; // 0 = not started; 1 = started; 2 = done
+ const onDisconnected = () => {
+ killConn = null; // before resolve, set killConn to null
+ if (error) {
+ reject(error);
+ } else if (state !== 2) {
+ reject(new Error("GM.runExclusive: Incomplete Action"));
+ } else {
+ resolve(result);
+ }
+ result = null; // GC
+ error = null; // GC
+ };
+ const onStart = async (con: MessageConnect) => {
+ if (killConn === null || state > 0) {
+ // already resolved (unexpected or by timeout)
+ con.disconnect();
+ return;
+ }
+ state = 1;
+ try {
+ result = await cb();
+ } catch (e) {
+ error = e;
+ }
+ state = 2;
+ con.sendMessage({
+ action: "done",
+ data: error ? false : typeof result,
+ });
+ con.disconnect();
+ onDisconnected(); // in case .disconnect() not working
+ };
+ this.connect("runExclusive", [key]).then((con) => {
+ if (killConn === null || state > 0) {
+ // already resolved (unexpected or by timeout)
+ con.disconnect();
+ return;
+ }
+ killConn = () => {
+ con.disconnect();
+ };
+ con.onDisconnect(onDisconnected);
+ con.onMessage((msg) => {
+ switch (msg.action) {
+ case "start":
+ onStart(con);
+ break;
+ }
+ });
+ });
+ if (timeout > 0) {
+ setTimeout(() => {
+ if (killConn === null || state > 0) return; // 执行开始了就不进行 timeout 操作
+ error = new Error("GM.runExclusive: Timeout Error");
+ killConn?.();
+ onDisconnected(); // in case .disconnect() not working
+ }, timeout);
+ }
+ });
+
+ return new Promise((resolve, reject) => {
+ stackAsyncTask(`runExclusive::${key}`, async () => {
+ try {
+ resolve(await taskAsync());
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+ }
}
// 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用)
diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts
index 1e5135605..fb8c51efd 100644
--- a/src/app/service/service_worker/gm_api/gm_api.ts
+++ b/src/app/service/service_worker/gm_api/gm_api.ts
@@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptDAO } from "@App/app/repo/scripts";
import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server";
-import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types";
+import type { ExtMessageSender, MessageConnect, MessageSend, TMessageCommAction } from "@Packages/message/types";
import { connect, sendMessage } from "@Packages/message/client";
import type { IMessageQueue } from "@Packages/message/message_queue";
import { type ValueService } from "@App/app/service/service_worker/value";
@@ -11,6 +11,7 @@ import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify";
import { cacheInstance } from "@App/app/cache";
import { type RuntimeService } from "../runtime";
import { getIcon, isFirefox, getCurrentTab, openInCurrentTab, cleanFileName, makeBlobURL } from "@App/pkg/utils/utils";
+import { deferred, type Deferred } from "@App/pkg/utils/utils";
import { type SystemConfig } from "@App/pkg/config/config";
import i18next, { i18nName } from "@App/locales/locales";
import FileSystemFactory from "@Packages/filesystem/factory";
@@ -44,6 +45,7 @@ import { headerModifierMap, headersReceivedMap } from "./gm_xhr";
import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr";
import { mightPrepareSetClipboard, setClipboard } from "../clipboard";
import { nativePageWindowOpen } from "../../offscreen/gm_api";
+import { stackAsyncTask } from "@App/pkg/utils/async_queue";
let generatedUniqueMarkerIDs = "";
let generatedUniqueMarkerIDWhen = "";
@@ -1305,6 +1307,46 @@ export default class GMApi {
}
}
+ @PermissionVerify.API({ link: ["GM.runExclusive", "GM_runExclusive"] })
+ runExclusive(request: GMApiRequest<[string]>, sender: IGetSender) {
+ if (!request.params || request.params.length < 1) {
+ throw new Error("param is failed");
+ }
+ const lockKey = request.params[0];
+ if (!sender.isType(GetSenderType.CONNECT)) {
+ throw new Error("GM_download ERROR: sender is not MessageConnect");
+ }
+ let msgConn: MessageConnect | undefined | null = sender.getConnect();
+ if (!msgConn) {
+ throw new Error("GM_download ERROR: msgConn is undefined");
+ }
+ let isConnDisconnected = false;
+ let d: Deferred | null = deferred();
+ let done: boolean = false;
+ const onDisconnected = () => {
+ if (isConnDisconnected) return;
+ isConnDisconnected = true;
+ d!.resolve(done);
+ msgConn = null; // release for GC
+ d = null; // release for GC
+ };
+ msgConn.onDisconnect(onDisconnected);
+ msgConn.onMessage((msg) => {
+ if (msg.action === "done") {
+ done = true;
+ msgConn?.disconnect();
+ onDisconnected(); // in case .disconnect() not working
+ }
+ });
+ stackAsyncTask(`${lockKey}`, async () => {
+ if (isConnDisconnected) return;
+ msgConn!.sendMessage({
+ action: "start",
+ });
+ return d!.promise;
+ });
+ }
+
handlerNotification() {
const send = async (
event: NotificationMessageOption["event"],
diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl
index e89cb2b05..425bd4954 100644
--- a/src/template/scriptcat.d.tpl
+++ b/src/template/scriptcat.d.tpl
@@ -304,6 +304,9 @@ declare const GM: {
/** Cookie 操作 */
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise;
+
+ /** cross-context exclusive execution */
+ runExclusive(key: string, callback: () => T | PromiseLike, timeout?: number): Promise;
};
/**
diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts
index d03005163..2ea00d095 100644
--- a/src/types/scriptcat.d.ts
+++ b/src/types/scriptcat.d.ts
@@ -304,6 +304,9 @@ declare const GM: {
/** Cookie 操作 */
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise;
+
+ /** cross-context exclusive execution */
+ runExclusive(key: string, callback: () => T | PromiseLike, timeout?: number): Promise;
};
/**