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; }; /**