Skip to content
185 changes: 185 additions & 0 deletions example/gm_run_exclusive.js
Original file line number Diff line number Diff line change
@@ -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 = `
<h3 style="margin-top:0">GM.runExclusive Demo</h3>
<p>Pick worker durations (ms):</p>
<div style="display:flex; flex-direction:row; gap: 4px;">
<input id="durations" value="1200,2400,3800,400"
style="width:140px; margin: 0;" />
<input id="timeout" value="5000"
style="width:45px; margin: 0;" />
<button id="run">Run Demo</button>
<button id="reset">Reset Counters</button>
</div>
<div id="iframeContainer"></div>
`;

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');
}


})();
86 changes: 86 additions & 0 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 {
Expand Down Expand Up @@ -1492,6 +1493,91 @@ export default class GMApi extends GM_Base {
public CAT_scriptLoaded() {
return this.loadScriptPromise;
}

@GMContext.API({ alias: "GM_runExclusive" })
["GM.runExclusive"]<T>(lockKey: string, cb: () => T | PromiseLike<T>, timeout: number = -1): Promise<T> {
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<T>((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 函数并导出(可供其他模块使用)
Expand Down
44 changes: 43 additions & 1 deletion src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -1303,6 +1305,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<boolean> | null = deferred<boolean>();
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"],
Expand Down
3 changes: 3 additions & 0 deletions src/template/scriptcat.d.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ declare const GM: {

/** Cookie 操作 */
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise<GMTypes.Cookie[]>;

/** cross-context exclusive execution */
runExclusive<T>(key: string, callback: () => T | PromiseLike<T>, timeout?: number): Promise<T>;
};

/**
Expand Down
3 changes: 3 additions & 0 deletions src/types/scriptcat.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ declare const GM: {

/** Cookie 操作 */
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise<GMTypes.Cookie[]>;

/** cross-context exclusive execution */
runExclusive<T>(key: string, callback: () => T | PromiseLike<T>, timeout?: number): Promise<T>;
};

/**
Expand Down
Loading