Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 {
Expand Down Expand Up @@ -1476,6 +1477,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 @@ -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<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