Skip to content
185 changes: 185 additions & 0 deletions example/gm_take_turn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// ==UserScript==
// @name GM.takeTurn Demo
// @namespace https://docs.scriptcat.org/
// @version 0.1.2
// @match https://example.com/*?takeTurn*
// @grant GM.takeTurn
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-start
// @allFrames
// ==/UserScript==

(async function () {
'use strict';

const delayMatch = location.href.match(/takeTurn(\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.takeTurn 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}?takeTurn${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(`takeTurn${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.takeTurn('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');
}


})();
87 changes: 87 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,8 @@ 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";

// 导入 CAT Agent API 以触发装饰器注册
// 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入
import CATAgentApi from "./cat_agent";
Expand Down Expand Up @@ -1625,6 +1627,91 @@ export default class GMApi extends GM_Base {
public CAT_scriptLoaded() {
return this.loadScriptPromise;
}

@GMContext.API({ alias: "GM_takeTurn" })
["GM.takeTurn"]<T>(lockKey: string, cb: () => T | PromiseLike<T>, timeout: number = -1): Promise<T> {
lockKey = `${lockKey}`; // 转化为字串
if (!lockKey || !this.scriptRes) {
throw new Error("GM.takeTurn: 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.takeTurn: 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("takeTurn", [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.takeTurn: Timeout Error");
killConn?.();
onDisconnected(); // in case .disconnect() not working
}, timeout);
}
});

return new Promise((resolve, reject) => {
stackAsyncTask(`takeTurn::${key}`, async () => {
try {
resolve(await taskAsync());
} catch (e) {
reject(e);
}
});
});
}
}

// 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用)
Expand Down
2 changes: 2 additions & 0 deletions src/app/service/offscreen/event_page_manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type LoggerCoreType from "../../logger/core";
import { EventPageOffscreenManager, InProcessMessage } from "./event_page_manager";

initTestEnv();
chromeMock.runtime.getURL = vi.fn().mockReturnValue("about:blank");

// keep_alive.ts 在模块顶层把 isFirefox() 固化为 boolFirefox 常量;测试环境不是 Firefox,
// 必须 mock isFirefox 并配合 vi.resetModules() 重新导入,Firefox 保活分支才会生效。
Expand All @@ -29,6 +30,7 @@ afterEach(() => {
isFirefoxMock.mockReturnValue(false);
vi.stubGlobal("Image", RealImage);
chromeMock.init();
chromeMock.runtime.getURL = vi.fn().mockReturnValue("about:blank");
for (const node of Array.from(document.documentElement.querySelectorAll("iframe,img"))) {
node.remove();
}
Expand Down
44 changes: 44 additions & 0 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
makeBlobURL,
stripUndefined,
} 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 @@ -59,6 +60,8 @@ 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";

import type { AgentService } from "@App/app/service/agent/service_worker/agent";
// 导入 Agent API 以触发装饰器注册
// 注意:不能使用 import "./gm_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入
Expand All @@ -74,6 +77,7 @@ import GMAgentModelApi from "./gm_agent_model";
void GMAgentModelApi;
import GMAgentOPFSApi from "./gm_agent_opfs";
void GMAgentOPFSApi;

import { nextSessionRuleId, removeSessionRuleIdEntry } from "./dnr_id_controller";
import type { DownloadCallback } from "../download";
import { detachDownloadCallback, startDownload } from "../download";
Expand Down Expand Up @@ -1545,6 +1549,46 @@ export default class GMApi {
}
}

@PermissionVerify.API({ link: ["GM.takeTurn", "GM_takeTurn"] })
takeTurn(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
10 changes: 9 additions & 1 deletion src/pkg/utils/script_compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,15 @@ describe("元数据指令支持判定", () => {

describe("GM 能力支持判定", () => {
it("注册表中的 GM API 视为支持", () => {
for (const grant of ["GM_setValue", "GM.setValue", "CAT_fileStorage", "CAT.agent.dom", "window.close"]) {
for (const grant of [
"GM_setValue",
"GM.setValue",
"GM_takeTurn",
"GM.takeTurn",
"CAT_fileStorage",
"CAT.agent.dom",
"window.close",
]) {
expect(isSupportedGrant(grant), grant).toBe(true);
}
});
Expand Down
Loading
Loading