Skip to content

Commit 61591ba

Browse files
committed
feat: add GM.runExclusive API for cross-context exclusive execution
1 parent be818dc commit 61591ba

File tree

5 files changed

+279
-1
lines changed

5 files changed

+279
-1
lines changed

example/gm_run_exclusive.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// ==UserScript==
2+
// @name GM.runExclusive Demo
3+
// @namespace https://docs.scriptcat.org/
4+
// @version 0.1.1
5+
// @match https://example.com/*?runExclusive*
6+
// @grant GM.runExclusive
7+
// @grant GM.setValue
8+
// @grant GM.getValue
9+
// @run-at document-start
10+
// @allFrames
11+
// ==/UserScript==
12+
13+
(async function () {
14+
'use strict';
15+
16+
const delayMatch = location.href.match(/runExclusive(\d+)/);
17+
const timeDelay = delayMatch ? +delayMatch[1] : 0;
18+
const isWorker = !!timeDelay;
19+
20+
/* ---------- Shared UI helpers ---------- */
21+
const panel = document.createElement('div');
22+
Object.assign(panel.style, {
23+
position: 'fixed',
24+
top: '10px',
25+
right: '10px',
26+
background: '#1e1e1e',
27+
color: '#e0e0e0',
28+
padding: '14px',
29+
borderRadius: '8px',
30+
fontFamily: 'monospace',
31+
zIndex: 99999,
32+
maxWidth: '420px'
33+
});
34+
document.documentElement.appendChild(panel);
35+
36+
const logContainer = document.createElement('div');
37+
panel.appendChild(logContainer);
38+
39+
const getTimeWithMilliseconds = date => `${date.toLocaleTimeString('it-US')}.${date.getMilliseconds()}`;
40+
41+
const log = (msg, color = '#ccc') => {
42+
const line = document.createElement('div');
43+
line.textContent = `[${getTimeWithMilliseconds(new Date())}] ${msg}`;
44+
line.style.color = color;
45+
logContainer.appendChild(line);
46+
};
47+
48+
/* ======================================================
49+
MAIN PAGE (Controller)
50+
====================================================== */
51+
if (!isWorker) {
52+
panel.innerHTML = `
53+
<h3 style="margin-top:0">GM.runExclusive Demo</h3>
54+
<p>Pick worker durations (ms):</p>
55+
<input id="durations" value="1200,2400,3800"
56+
style="width:100%;margin-bottom:8px">
57+
<button id="run">Run Demo</button>
58+
<button id="reset">Reset Counters</button>
59+
<hr>
60+
<div id="iframeContainer"></div>
61+
`;
62+
63+
const iframeContainer = panel.querySelector('#iframeContainer');
64+
65+
panel.querySelector('#reset').onclick = async () => {
66+
await GM.setValue('mValue01', 0);
67+
await GM.setValue('order', 0);
68+
iframeContainer.innerHTML = '';
69+
log('Shared counters reset', '#ff0');
70+
};
71+
72+
panel.querySelector('#run').onclick = async () => {
73+
iframeContainer.innerHTML = '';
74+
await GM.setValue('mValue01', 0);
75+
await GM.setValue('order', 0);
76+
77+
const delays = panel
78+
.querySelector('#durations')
79+
.value.split(',')
80+
.map(v => +v.trim())
81+
.filter(Boolean);
82+
83+
log(`Launching workers: ${delays.join(', ')}`, '#0f0');
84+
85+
delays.forEach(delay => {
86+
const iframe = document.createElement('iframe');
87+
iframe.src = `${location.pathname}?runExclusive${delay}`;
88+
iframe.style.width = '100%';
89+
iframe.style.height = '220px';
90+
iframe.style.border = '1px solid #444';
91+
iframe.style.marginTop = '8px';
92+
iframeContainer.appendChild(iframe);
93+
});
94+
};
95+
96+
window.addEventListener('message', (e) => {
97+
if (e.data?.type !== 'close-worker') return;
98+
const iframes = iframeContainer.querySelectorAll('iframe');
99+
for (const iframe of iframes) {
100+
if (iframe.src.includes(`runExclusive${e.data.delay}`)) {
101+
iframe.remove();
102+
log(`Closed worker ${e.data.delay}ms`, '#ff9800');
103+
return;
104+
}
105+
}
106+
});
107+
108+
return;
109+
}
110+
111+
/* ======================================================
112+
WORKER IFRAME
113+
====================================================== */
114+
115+
const closeBtn = document.createElement('button');
116+
closeBtn.textContent = 'Close worker';
117+
Object.assign(closeBtn.style, {
118+
marginTop: '8px',
119+
padding: '4px 8px',
120+
cursor: 'pointer'
121+
});
122+
closeBtn.onclick = () => {
123+
window.parent.postMessage({ type: 'close-worker', delay: timeDelay }, '*');
124+
};
125+
panel.appendChild(closeBtn);
126+
127+
log(`Worker ${timeDelay}ms loaded`, '#fff');
128+
log('Waiting for exclusive lock…', '#0af');
129+
130+
const startWait = performance.now();
131+
132+
try {
133+
const result = await GM.runExclusive('demo-lock-key', async () => {
134+
const waited = Math.round(performance.now() - startWait);
135+
136+
const order = (await GM.getValue('order')) + 1;
137+
await GM.setValue('order', order);
138+
139+
log(`Lock acquired (#${order}, waited ${waited}ms)`, '#0f0');
140+
141+
const val = await GM.getValue('mValue01');
142+
await GM.setValue('mValue01', val + timeDelay);
143+
144+
log(`Working ${timeDelay}ms…`, '#ff0');
145+
await new Promise(r => setTimeout(r, timeDelay));
146+
147+
const final = await GM.getValue('mValue01');
148+
log(`Done. Shared value = ${final}`, '#f55');
149+
150+
return { order, waited, final };
151+
}, 5000);
152+
log(`Result: ${JSON.stringify(result)}`, '#fff');
153+
} catch (e) {
154+
log(`Error: ${JSON.stringify(e?.message || e)}`, '#f55');
155+
}
156+
157+
158+
})();

src/app/service/content/gm_api/gm_api.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,6 +1383,78 @@ export default class GMApi extends GM_Base {
13831383
CAT_scriptLoaded() {
13841384
return this.loadScriptPromise;
13851385
}
1386+
1387+
@GMContext.API({ alias: "GM_runExclusive" })
1388+
["GM.runExclusive"]<T>(lockKey: string, cb: () => T | PromiseLike<T>, timeout: number = -1): Promise<T> {
1389+
lockKey = `${lockKey}`; // 转化为字串
1390+
if (!lockKey || !this.scriptRes) {
1391+
throw new Error("GM.runExclusive: Invalid Calling");
1392+
}
1393+
const key = `${getStorageName(this.scriptRes).replace(/:/g, ":_")}::${lockKey.replace(/:/g, ":_")}`;
1394+
return new Promise((resolve, reject) => {
1395+
let killConn: (() => any) | null | undefined = undefined;
1396+
let error: any;
1397+
let result: any;
1398+
let done = false;
1399+
const onDisconnected = () => {
1400+
killConn = null; // before resolve, set killConn to null
1401+
if (error) {
1402+
reject(error);
1403+
} else if (!done) {
1404+
reject(new Error("GM.runExclusive: Incomplete Action"));
1405+
} else {
1406+
resolve(result);
1407+
}
1408+
result = null; // GC
1409+
error = null; // GC
1410+
};
1411+
const onStart = async (con: MessageConnect) => {
1412+
if (killConn === null || done) {
1413+
// already resolved
1414+
con.disconnect();
1415+
return;
1416+
}
1417+
try {
1418+
result = await cb();
1419+
} catch (e) {
1420+
error = e;
1421+
}
1422+
done = true;
1423+
con.sendMessage({
1424+
action: "done",
1425+
data: error ? false : typeof result,
1426+
});
1427+
con.disconnect();
1428+
onDisconnected(); // in case .disconnect() not working
1429+
};
1430+
this.connect("runExclusive", [key]).then((con) => {
1431+
if (killConn === null || done) {
1432+
// already resolved
1433+
con.disconnect();
1434+
return;
1435+
}
1436+
killConn = () => {
1437+
con.disconnect();
1438+
};
1439+
con.onDisconnect(onDisconnected);
1440+
con.onMessage((msg) => {
1441+
switch (msg.action) {
1442+
case "start":
1443+
onStart(con);
1444+
break;
1445+
}
1446+
});
1447+
});
1448+
if (timeout > 0) {
1449+
setTimeout(() => {
1450+
if (killConn === null || done) return;
1451+
error = new Error("GM.runExclusive: Timeout Error");
1452+
killConn?.();
1453+
onDisconnected(); // in case .disconnect() not working
1454+
}, timeout);
1455+
}
1456+
});
1457+
}
13861458
}
13871459

13881460
// 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用)

src/app/service/service_worker/gm_api/gm_api.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core";
22
import Logger from "@App/app/logger/logger";
33
import { ScriptDAO } from "@App/app/repo/scripts";
44
import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server";
5-
import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types";
5+
import type { ExtMessageSender, MessageConnect, MessageSend, TMessageCommAction } from "@Packages/message/types";
66
import { connect, sendMessage } from "@Packages/message/client";
77
import type { IMessageQueue } from "@Packages/message/message_queue";
88
import { type ValueService } from "@App/app/service/service_worker/value";
@@ -11,6 +11,7 @@ import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify";
1111
import { cacheInstance } from "@App/app/cache";
1212
import { type RuntimeService } from "../runtime";
1313
import { getIcon, isFirefox, getCurrentTab, openInCurrentTab, cleanFileName, makeBlobURL } from "@App/pkg/utils/utils";
14+
import { deferred, type Deferred } from "@App/pkg/utils/utils";
1415
import { type SystemConfig } from "@App/pkg/config/config";
1516
import i18next, { i18nName } from "@App/locales/locales";
1617
import FileSystemFactory from "@Packages/filesystem/factory";
@@ -44,6 +45,7 @@ import { headerModifierMap, headersReceivedMap } from "./gm_xhr";
4445
import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr";
4546
import { mightPrepareSetClipboard, setClipboard } from "../clipboard";
4647
import { nativePageWindowOpen } from "../../offscreen/gm_api";
48+
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
4749

4850
let generatedUniqueMarkerIDs = "";
4951
let generatedUniqueMarkerIDWhen = "";
@@ -1305,6 +1307,46 @@ export default class GMApi {
13051307
}
13061308
}
13071309

1310+
@PermissionVerify.API({ link: ["GM.runExclusive", "GM_runExclusive"] })
1311+
runExclusive(request: GMApiRequest<[string]>, sender: IGetSender) {
1312+
if (!request.params || request.params.length < 1) {
1313+
throw new Error("param is failed");
1314+
}
1315+
const lockKey = request.params[0];
1316+
if (!sender.isType(GetSenderType.CONNECT)) {
1317+
throw new Error("GM_download ERROR: sender is not MessageConnect");
1318+
}
1319+
let msgConn: MessageConnect | undefined | null = sender.getConnect();
1320+
if (!msgConn) {
1321+
throw new Error("GM_download ERROR: msgConn is undefined");
1322+
}
1323+
let isConnDisconnected = false;
1324+
let d: Deferred<boolean> | null = deferred<boolean>();
1325+
let done: boolean = false;
1326+
const onDisconnected = () => {
1327+
if (isConnDisconnected) return;
1328+
isConnDisconnected = true;
1329+
d!.resolve(done);
1330+
msgConn = null; // release for GC
1331+
d = null; // release for GC
1332+
};
1333+
msgConn.onDisconnect(onDisconnected);
1334+
msgConn.onMessage((msg) => {
1335+
if (msg.action === "done") {
1336+
done = true;
1337+
msgConn?.disconnect();
1338+
onDisconnected(); // in case .disconnect() not working
1339+
}
1340+
});
1341+
stackAsyncTask(`${lockKey}`, async () => {
1342+
if (isConnDisconnected) return;
1343+
msgConn!.sendMessage({
1344+
action: "start",
1345+
});
1346+
return d!.promise;
1347+
});
1348+
}
1349+
13081350
handlerNotification() {
13091351
const send = async (
13101352
event: NotificationMessageOption["event"],

src/template/scriptcat.d.tpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ declare const GM: {
304304

305305
/** Cookie 操作 */
306306
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise<GMTypes.Cookie[]>;
307+
308+
/** cross-context exclusive execution */
309+
runExclusive<T>(key: string, callback: () => T | PromiseLike<T>, timeout?: number): Promise<T>;
307310
};
308311

309312
/**

src/types/scriptcat.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ declare const GM: {
304304

305305
/** Cookie 操作 */
306306
cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise<GMTypes.Cookie[]>;
307+
308+
/** cross-context exclusive execution */
309+
runExclusive<T>(key: string, callback: () => T | PromiseLike<T>, timeout?: number): Promise<T>;
307310
};
308311

309312
/**

0 commit comments

Comments
 (0)