From c21f52f91ab5dcad6bb9f1d8f7a5b9ddbdd0807a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:11:55 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20=E5=BC=82=E6=AD=A5=20GM.getValue?= =?UTF-8?q?/getValues/listValues=20=E8=AF=BB=E5=8F=96=E5=89=8D=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9C=80=E6=96=B0=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重新设计自 scriptscat/scriptcat#950:valueDAO 在每次值变更时以严格递增的 updatetime 记录版本,valueUpdate 推送携带该 updatetime;页面端异步 GM.* 读取前经 internalApiWaitForFreshValueState 取得当前 updatetime,未同步时 等待对应 valueUpdate 到达后才读取本地缓存,避免读到过期值或次序错乱。 Co-Authored-By: Claude Fable 5 --- .../service/content/create_context.test.ts | 2 + src/app/service/content/gm_api/gm_api.test.ts | 151 ++++++++++++++-- src/app/service/content/gm_api/gm_api.ts | 111 +++++++++--- src/app/service/content/types.ts | 1 + .../service/service_worker/gm_api/gm_api.ts | 14 ++ src/app/service/service_worker/value.test.ts | 164 ++++++++++++++++++ src/app/service/service_worker/value.ts | 37 +++- 7 files changed, 443 insertions(+), 37 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..73b355057 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -161,6 +161,7 @@ describe.concurrent("createContext", () => { sender: { runFlag: "other-run-flag", tabId: 7 }, entries: [["foo", encodeRValue("next"), encodeRValue("bar")]], valueUpdated: true, + updatetime: Date.now(), }); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); @@ -175,6 +176,7 @@ describe.concurrent("createContext", () => { sender: { runFlag: "other-run-flag", tabId: 8 }, entries: [["foo", encodeRValue("again"), encodeRValue("next")]], valueUpdated: true, + updatetime: Date.now(), }); expect(listener).toHaveBeenCalledTimes(1); }); diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index 8a94e1b66..39111b158 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, type Mock, vi } from "vitest"; import ExecScript from "../exec_script"; import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; import type { GMInfoEnv, ScriptFunc } from "../types"; @@ -6,6 +6,7 @@ import { compileScript, compileScriptCode } from "../utils"; import type { Message } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getStorageName } from "@App/pkg/utils/utils"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -176,6 +177,31 @@ describe.concurrent("window.*", () => { }); describe.concurrent("GM Api", () => { + // 异步 GM.* 取值会先发出 internalApiWaitForFreshValueState,等 valueUpdate 回推最新 + // updatetime 后才读取本地缓存;这里模拟 service_worker 的该次回推 + const respondFreshValueState = ( + mockSendMessage: Mock<(...args: any[]) => any>, + exec: ExecScript, + script: ScriptLoadInfo + ) => { + const idx = mockSendMessage.mock.calls.findIndex((entry) => { + return entry?.[0]?.data?.api === "internalApiWaitForFreshValueState"; + }); + expect(idx).toBeGreaterThanOrEqual(0); + const actualCall = mockSendMessage.mock.calls[idx][0]; + const [id] = actualCall.data.params; + expect(id).toBeTypeOf("string"); + expect(id.length).greaterThan(0); + exec.valueUpdate({ + id, + entries: [], + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + valueUpdated: false, + updatetime: Date.now(), + }); + }; it.concurrent("GM_getValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test: "ok" }; @@ -192,7 +218,91 @@ describe.concurrent("GM Api", () => { const ret = await exec.exec(); expect(ret).toEqual("ok"); }); - it.concurrent("GM.getValue", async () => { + it.concurrent("GM.getValue 应等待最新值同步后返回", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.value = { test: "ok" }; + script.metadata.grant = ["GM.getValue"]; + script.code = `return GM.getValue("test").then(v=>v+"!");`; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script)); + const retPromise = exec.exec(); + respondFreshValueState(mockSendMessage, exec, script); + const ret = await retPromise; + expect(ret).toEqual("ok!"); + }); + + it.concurrent("GM.getValue 遇到更新的 updatetime 时应等待对应 valueUpdate 才读取", async () => { + const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.value = { k: "old" }; + script.metadata.grant = ["GM.getValue"]; + script.code = `return GM.getValue("k").then(v1=>GM.getValue("k").then(v2=>[v1, v2]));`; + const t0 = Date.now(); + const t1 = t0 + 1000; + const internalCalls: any[] = []; + const mockSendMessage = vi.fn().mockImplementation((msg: any) => { + if (msg?.data?.api === "internalApiWaitForFreshValueState") { + internalCalls.push(msg); + // 第一次返回当前 updatetime t0;第二次返回更新的 t1,模拟其他环境已写入新值 + return Promise.resolve({ code: 0, data: internalCalls.length === 1 ? t0 : t1 }); + } + return Promise.resolve({ code: 0 }); + }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script)); + const retPromise = exec.exec(); + // 第一次读取:以 id 对应的 valueUpdate 回推 t0 + expect(internalCalls.length).toBe(1); + exec.valueUpdate({ + id: internalCalls[0].data.params[0], + entries: [], + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + valueUpdated: false, + updatetime: t0, + }); + await vi.waitFor(() => expect(internalCalls.length).toBe(2)); + // 第二次读取:service_worker 返回 t1 > t0,在 valueUpdate 到达前不得完成 + let settled = false; + retPromise.then(() => { + settled = true; + }); + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); + // t1 的 valueUpdate 到达后放行,且读取到同步后的新值 + exec.valueUpdate({ + id: "", + entries: [["k", encodeRValue("new"), encodeRValue("old")]], + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: "user", tabId: -2 }, + valueUpdated: true, + updatetime: t1, + }); + const ret = await retPromise; + expect(ret).toEqual(["old", "new"]); + }); + + it.concurrent("GM.getValue 在 message 无效时直接返回本地缓存", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test: "ok" }; script.metadata.grant = ["GM.getValue"]; @@ -247,20 +357,26 @@ describe.concurrent("GM Api", () => { expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); - it.concurrent("GM.listValues", async () => { + it.concurrent("GM.listValues 应等待最新值同步后返回", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: "45", test3: "67" }; script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; const exec = new ExecScript(script, { envPrefix: "scripting", - message: undefined as any, + message: mockMessage, contentMsg: undefined as any, code: nilFn, envInfo, }); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + respondFreshValueState(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret).toEqual("test1-test2-test3"); }); @@ -273,15 +389,21 @@ describe.concurrent("GM Api", () => { script.value.test1 = "40"; script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; const exec = new ExecScript(script, { envPrefix: "scripting", - message: undefined as any, + message: mockMessage, contentMsg: undefined as any, code: nilFn, envInfo, }); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + respondFreshValueState(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); @@ -311,20 +433,26 @@ describe.concurrent("GM Api", () => { expect(ret2.test4).toEqual("default"); }); - it.concurrent("GM.getValues", async () => { + it.concurrent("GM.getValues 应等待最新值同步后返回", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: 45, test3: "67" }; script.metadata.grant = ["GM.getValues"]; script.code = `return GM.getValues(["test2", "test3", "test1"]).then(v=>v);`; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; const exec = new ExecScript(script, { envPrefix: "scripting", - message: undefined as any, + message: mockMessage, contentMsg: undefined as any, code: nilFn, envInfo, }); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + respondFreshValueState(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret.test1).toEqual("23"); expect(ret.test2).toEqual(45); expect(ret.test3).toEqual("67"); @@ -1122,6 +1250,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 storageName: script.uuid, sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, valueUpdated: true, + updatetime: Date.now(), }); const ret = await retPromise; expect(ret).toEqual({ name: "param1", oldValue: undefined, newValue: 123, remote: false }); @@ -1162,6 +1291,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 storageName: "testStorage", sender: { runFlag: "user", tabId: -2 }, valueUpdated: true, + updatetime: Date.now(), }); const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); @@ -1202,6 +1332,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 storageName: script.uuid, sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, valueUpdated: true, + updatetime: Date.now(), }); const ret = await retPromise; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..3d812b4ab 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -11,7 +11,8 @@ import type { TScriptMenuItemKey, MessageRequest, } from "@App/app/service/service_worker/types"; -import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; +import type { Deferred } from "@App/pkg/utils/utils"; +import { base64ToBlob, deferred, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -61,6 +62,15 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap = new Map(); +const generateValChangeId = () => { + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; + } + return `${valChangeRandomId}::${++valChangeCounterId}`; +}; + const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 @@ -113,6 +123,14 @@ class GM_Base implements IGM_Base { @GMContext.protected() protected loadScriptPromise: Promise | undefined; + // 本执行环境已同步到的 valueDAO updatetime;只由 valueUpdate 推送更新 + @GMContext.protected() + protected valueDaoUpdatetime: number | undefined; + + // 等待本地缓存同步到指定 updatetime 的读取请求 + @GMContext.protected() + protected readFreshes: Map> | undefined; + constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); Object.assign(this, options); @@ -171,7 +189,7 @@ class GM_Base implements IGM_Base { public valueUpdate(data: ValueUpdateDataEncoded) { if (!this.scriptRes || !this.valueChangeListener) return; const scriptRes = this.scriptRes; - const { id, uuid, entries, storageName, sender, valueUpdated } = data; + const { id, uuid, entries, storageName, sender, valueUpdated, updatetime } = data; if (uuid === scriptRes.uuid || storageName === getStorageName(scriptRes)) { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; @@ -198,6 +216,19 @@ class GM_Base implements IGM_Base { this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); } } + if (updatetime && !(this.valueDaoUpdatetime! >= updatetime)) { + this.valueDaoUpdatetime = updatetime; + const readFreshes = this.readFreshes; + if (readFreshes) { + // 本地缓存已同步到 updatetime,放行所有不晚于该时点的等待读取 + for (const [t, d] of readFreshes) { + if (updatetime >= t) { + readFreshes.delete(t); + d.resolve(updatetime); + } + } + } + } } } @@ -253,6 +284,42 @@ export default class GMApi extends GM_Base { ); } + /** + * 确保本地缓存 values 不旧于 valueDAO 当前状态,供异步 GM.* 读取前调用。 + * - 尚未收过任何 valueUpdate(valueDaoUpdatetime 为 undefined)时,附带 id 让 + * service_worker 走一次空 setValues,借同一条推送通道回推当前 updatetime; + * - service_worker 返回的 updatetime 较新时,等待对应的 valueUpdate 到达。 + */ + static async waitForFreshValueState(a: GMApi): Promise { + if (!a.scriptRes || !a.message) return; + let id = ""; + let d: Deferred | null = null; + if (!a.valueDaoUpdatetime) { + id = generateValChangeId(); + d = deferred(); + valueChangePromiseMap.set(id, d.resolve); + } + const updatetime = await a.sendMessage("internalApiWaitForFreshValueState", [id]); + if (updatetime === undefined) { + // message 通道失效(如 extension context invalidated)时不会再有 valueUpdate 推送, + // 放弃等待,直接读取本地缓存 + if (id) valueChangePromiseMap.delete(id); + return; + } + if (d) { + await d.promise; + } + if (updatetime && a.valueDaoUpdatetime && a.valueDaoUpdatetime < updatetime) { + // 该 updatetime 对应的 valueUpdate 尚未到达本环境,等待推送同步本地缓存 + const readFreshes = (a.readFreshes ||= new Map>()); + let dd = readFreshes.get(updatetime); + if (!dd) { + readFreshes.set(updatetime, (dd = deferred())); + } + await dd.promise; + } + } + static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; const ret = a.scriptRes.value[key]; @@ -274,21 +341,15 @@ export default class GMApi extends GM_Base { @GMContext.API() public "GM.getValue"(key: string, defaultValue?: any): Promise { // 兼容GM.getValue - return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); - resolve(ret); + return waitForFreshValueState(this).then(() => { + return _GM_getValue(this, key, defaultValue); }); } static _GM_setValue(a: GMApi, promise: any, key: string, value: any) { key = `${key}`; if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } @@ -313,12 +374,7 @@ export default class GMApi extends GM_Base { static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } @@ -379,10 +435,10 @@ export default class GMApi extends GM_Base { @GMContext.API() public "GM.listValues"(): Promise { // Asynchronous wrapper for GM_listValues to support GM.listValues - return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); + return waitForFreshValueState(this).then(() => { + if (!this.scriptRes) return []; const keys = Object.keys(this.scriptRes.value); - resolve(keys); + return keys; }); } @@ -431,9 +487,8 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_getValues"] }) public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); - resolve(ret); + return waitForFreshValueState(this).then(() => { + return this.GM_getValues(keysOrDefaults); }); } @@ -1614,4 +1669,12 @@ export default class GMApi extends GM_Base { export const { createGMBase } = GM_Base; // 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 -const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_download, _GM_notification } = GMApi; +const { + waitForFreshValueState, + _GM_getValue, + _GM_cookie, + _GM_setValue, + _GM_setValues, + _GM_download, + _GM_notification, +} = GMApi; diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..cbc50bdab 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -30,6 +30,7 @@ export type ValueUpdateDataEncoded = { storageName: string; // 储存name sender: ValueUpdateSender; valueUpdated: boolean; + updatetime: number; // valueDAO 该 storageName 当前的 updatetime,用于异步读取的新鲜度判断 }; // gm_api.ts 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 99a5e2b68..0f0acf9be 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -584,6 +584,20 @@ export default class GMApi { return true; } + // 异步 GM.getValue/getValues/listValues 读取前的内部同步调用,不对应任何 @grant + @PermissionVerify.API({ default: true }) + async internalApiWaitForFreshValueState(request: GMApiRequest<[string]>, sender: IGetSender) { + if (!request.params || request.params.length !== 1) { + throw new Error("param is failed"); + } + const [id] = request.params; + const valueSender = { + runFlag: request.runFlag, + tabId: sender.getSender()?.tab?.id || -1, + }; + return await this.value.waitForFreshValueState(request.script.uuid, id, valueSender); + } + @PermissionVerify.API({ link: ["GM_deleteValue", "GM_deleteValues"] }) async GM_setValue(request: GMApiRequest<[string, string, any?]>, sender: IGetSender) { if (!request.params || request.params.length < 2) { diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..45d4fe4c6 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -453,6 +453,170 @@ describe("ValueService - setValue 方法测试", () => { }); }); +describe("ValueService - updatetime 与 waitForFreshValueState", () => { + let valueService: ValueService; + let mockScriptDAO: ScriptDAO; + let mockValueDAO: ValueDAO; + + const createMockScript = (overrides: Partial