diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6b82ed296..7351e523d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -64,8 +64,8 @@ export default class ExecScript { this.sandboxContext?.emitEvent(event, eventId, data); } - valueUpdate(data: ValueUpdateDataEncoded) { - this.sandboxContext?.valueUpdate(data); + valueUpdate(storageName: string, uuid: string, data: ValueUpdateDataEncoded[]) { + this.sandboxContext?.valueUpdate(storageName, uuid, data); } execContext: any; diff --git a/src/app/service/content/gm_api.test.ts b/src/app/service/content/gm_api.test.ts index 05e8b8f24..a40952df3 100644 --- a/src/app/service/content/gm_api.test.ts +++ b/src/app/service/content/gm_api.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, type Mock } from "vitest"; import ExecScript from "./exec_script"; import type { ScriptLoadInfo } from "../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 { encodeMessage } from "@App/pkg/utils/message_value"; import { v4 as uuidv4 } from "uuid"; +import { getStorageName } from "@App/pkg/utils/utils"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -107,6 +108,40 @@ describe.concurrent("window.*", () => { }); describe.concurrent("GM Api", () => { + const valueDaoUpdatetimeFix = ( + mockSendMessage: Mock<(...args: any[]) => any>, + exec: ExecScript, + script: ScriptLoadInfo + ) => { + const forceUpdateTimeRefreshIdx = mockSendMessage.mock.calls.findIndex((entry) => { + return entry?.[0]?.data?.api === "internalApiWaitForFreshValueState"; + }); + if (forceUpdateTimeRefreshIdx >= 0) { + const actualCall = mockSendMessage.mock.calls[forceUpdateTimeRefreshIdx][0]; + expect(mockSendMessage).toHaveBeenNthCalledWith( + forceUpdateTimeRefreshIdx + 1, + expect.objectContaining({ + action: "content/runtime/gmApi", + data: { + api: "internalApiWaitForFreshValueState", + params: [expect.stringMatching(/^.+::\d+$/)], + runFlag: expect.any(String), + uuid: undefined, + }, + }) + ); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: actualCall.data.params[0], + entries: encodeMessage([["TEST_NON_EXIST_REMOVAL", undefined, undefined]]), + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + updatetime: Date.now(), + }, + ]); + } + }; it.concurrent("GM_getValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test: "ok" }; @@ -123,10 +158,15 @@ describe.concurrent("GM Api", () => { script.value = { test: "ok" }; script.metadata.grant = ["GM.getValue"]; script.code = `return GM.getValue("test").then(v=>v+"!");`; - // @ts-ignore - const exec = new ExecScript(script, undefined, undefined, nilFn, envInfo); + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + valueDaoUpdatetimeFix(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret).toEqual("ok!"); }); @@ -155,7 +195,7 @@ describe.concurrent("GM Api", () => { const exec = new ExecScript(script, undefined, undefined, nilFn, envInfo); exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); - expect(ret).toEqual("test5-test2-test3-test1"); // TM也沒有sort + expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); it.concurrent("GM.listValues", async () => { @@ -163,10 +203,15 @@ describe.concurrent("GM Api", () => { script.value = { test1: "23", test2: "45", test3: "67" }; script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; - // @ts-ignore - const exec = new ExecScript(script, undefined, undefined, nilFn, envInfo); + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + valueDaoUpdatetimeFix(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret).toEqual("test1-test2-test3"); }); @@ -179,11 +224,16 @@ describe.concurrent("GM Api", () => { script.value.test1 = "40"; script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; - // @ts-ignore - const exec = new ExecScript(script, undefined, undefined, nilFn, envInfo); + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); - expect(ret).toEqual("test5-test2-test3-test1"); // TM也沒有sort + const retPromise = exec.exec(); + valueDaoUpdatetimeFix(mockSendMessage, exec, script); + const ret = await retPromise; + expect(ret).toEqual("test5-test2-test3-test1"); // TM也没有sort }); it.concurrent("GM_getValues", async () => { @@ -212,10 +262,15 @@ describe.concurrent("GM Api", () => { script.value = { test1: "23", test2: 45, test3: "67" }; script.metadata.grant = ["GM.getValues"]; script.code = `return GM.getValues(["test2", "test3", "test1"]).then(v=>v);`; - // @ts-ignore - const exec = new ExecScript(script, undefined, undefined, nilFn, envInfo); + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo); exec.scriptFunc = compileScript(compileScriptCode(script)); - const ret = await exec.exec(); + const retPromise = exec.exec(); + valueDaoUpdatetimeFix(mockSendMessage, exec, script); + const ret = await retPromise; expect(ret.test1).toEqual("23"); expect(ret.test2).toEqual(45); expect(ret.test3).toEqual("67"); @@ -493,7 +548,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValues", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the object payload expect.objectContaining({ k: expect.stringMatching(/^##[\d.]+##$/), @@ -519,7 +574,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValues", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the object payload expect.objectContaining({ k: expect.stringMatching(/^##[\d.]+##$/), @@ -570,7 +625,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValues", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the object payload expect.objectContaining({ k: expect.stringMatching(/^##[\d.]+##$/), @@ -596,7 +651,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValue", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the string payload "b", ], @@ -641,7 +696,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValues", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the object payload expect.objectContaining({ k: expect.stringMatching(/^##[\d.]+##$/), @@ -667,7 +722,7 @@ describe.concurrent("GM_value", () => { api: "GM_setValues", params: [ // event id - expect.stringMatching(/^.+::\d$/), + expect.stringMatching(/^.+::\d+$/), // the string payload expect.objectContaining({ k: expect.stringMatching(/^##[\d.]+##$/), @@ -708,14 +763,16 @@ describe.concurrent("GM_value", () => { const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-1", - entries: encodeMessage([["param1", 123, undefined]]), - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-1", + entries: encodeMessage([["param1", 123, undefined]]), + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + updatetime: Date.now(), + }, + ]); const ret = await retPromise; expect(ret).toEqual({ name: "param1", oldValue: undefined, newValue: 123, remote: false }); }); @@ -743,14 +800,16 @@ describe.concurrent("GM_value", () => { const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-2", - entries: encodeMessage([["param2", 456, undefined]]), - uuid: script.uuid, - storageName: "testStorage", - sender: { runFlag: "user", tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-2", + entries: encodeMessage([["param2", 456, undefined]]), + uuid: script.uuid, + storageName: "testStorage", + sender: { runFlag: "user", tabId: -2 }, + updatetime: Date.now(), + }, + ]); const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); @@ -778,14 +837,16 @@ describe.concurrent("GM_value", () => { expect(id).toBeTypeOf("string"); expect(id.length).greaterThan(0); // 触发valueUpdate - exec.valueUpdate({ - id: id, - entries: encodeMessage([["a", 123, undefined]]), - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: id, + entries: encodeMessage([["a", 123, undefined]]), + uuid: script.uuid, + storageName: getStorageName(script), + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + updatetime: Date.now(), + }, + ]); const ret = await retPromise; expect(ret).toEqual(123); diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 3eaf219f9..b0d4dc085 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -9,7 +9,8 @@ import type { TScriptMenuItemID, TScriptMenuItemKey, } from "../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"; @@ -21,12 +22,13 @@ import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "./listener_manager"; import { decodeMessage, encodeMessage } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; // 内部函数呼叫定义 export interface IGM_Base { sendMessage(api: string, params: any[]): Promise; connect(api: string, params: any[]): Promise; - valueUpdate(data: ValueUpdateDataEncoded): void; + valueUpdate(storageName: string, uuid: string, data: ValueUpdateDataEncoded[]): void; emitEvent(event: string, eventId: string, data: any): void; } @@ -36,7 +38,19 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +type PromiseResolve = ((...args: any[]) => any) | null | undefined; + +const valueChangePromiseMap = new Map(); + +const generateValChangeId = () => { + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; + } + const id = `${valChangeRandomId}::${++valChangeCounterId}`; + return id; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { @@ -47,6 +61,21 @@ const execEnvInit = (execEnv: GMApi) => { } }; +const emitToListener = ( + a: GM_Base, + key: string, + oldValue: any, + value: any, + remote: boolean, + tabId: number | undefined // 注: tabId 的提供不在标准 GM API 的定义 +) => { + // 在 valueUpdate 完成后才放行。避免卡住 valueUpdate 线程 + stackAsyncTask("valueUpdateEventListenerEmit", () => { + // 不等待结果 + a.valueChangeListener?.execute(key, oldValue, value, remote, tabId); + }); +}; + // GM_Base 定义内部用变量和函数。均使用@protected // 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成 class GM_Base implements IGM_Base { @@ -66,7 +95,7 @@ class GM_Base implements IGM_Base { // Extension Context 无效时释放 valueChangeListener @GMContext.protected() - protected valueChangeListener?: ListenerManager; + public valueChangeListener?: ListenerManager; // Extension Context 无效时释放 EE @GMContext.protected() @@ -103,6 +132,15 @@ class GM_Base implements IGM_Base { @GMContext.protected() public setInvalidContext!: () => void; + @GMContext.protected() + public readFreshes: Map> | undefined; + + @GMContext.protected() + public extValueStoreCopy: Record | undefined; // 使每个tab的valueChange次序保持一致 + + @GMContext.protected() + valueDaoUpdatetime: number | undefined; + // 单次回调使用 @GMContext.protected() public async sendMessage(api: string, params: any[]) { @@ -142,34 +180,63 @@ class GM_Base implements IGM_Base { } @GMContext.protected() - public valueUpdate(data: ValueUpdateDataEncoded) { - if (!this.scriptRes || !this.valueChangeListener) return; + public valueUpdate(storageName: string, uuid: string, list: ValueUpdateDataEncoded[]) { + if (!this.scriptRes) return; const scriptRes = this.scriptRes; - const { id, uuid, entries, storageName, sender, valueUpdated } = data; - if (uuid === scriptRes.uuid || storageName === getStorageName(scriptRes)) { + let lastUpdateTime = 0; + if (uuid == scriptRes.uuid || storageName === getStorageName(scriptRes)) { + const hold = deferred(); + // 避免立即 emit + stackAsyncTask("valueUpdateEventListenerEmit", () => hold.promise); + // ----- 更新 valueStore (同步) ----- + scriptRes.value = this.extValueStoreCopy || scriptRes.value; const valueStore = scriptRes.value; - const remote = sender.runFlag !== this.runFlag; - if (!remote && id) { - const fn = valueChangePromiseMap.get(id); - if (fn) { - valueChangePromiseMap.delete(id); - fn(); + for (const data of list) { + const { id, entries, sender, updatetime } = data; + const remote = sender.runFlag !== this.runFlag; + if (!remote && id) { + const fn = valueChangePromiseMap.get(id); + if (fn) { + valueChangePromiseMap.delete(id); + fn(); + } + } + const isUpdated = entries.k.length > 0; + if (isUpdated) { + const valueChanges = decodeMessage(entries); + for (const [key, value, oldValue] of valueChanges) { + // 触发,并更新值 + if (value === undefined) { + if (valueStore[key] !== undefined) { + delete valueStore[key]; + } + } else { + valueStore[key] = value; + } + if (this.valueChangeListener) { + emitToListener(this, key, oldValue, value, remote, sender.tabId); + } + } + } + if (updatetime) { + lastUpdateTime = updatetime; } } - if (valueUpdated) { - const valueChanges = decodeMessage(entries); - for (const [key, value, oldValue] of valueChanges) { - // 触发,并更新值 - if (value === undefined) { - if (valueStore[key] !== undefined) { - delete valueStore[key]; + this.extValueStoreCopy = { ...valueStore }; + // ----- 更新 valueStore (同步) ----- + if (lastUpdateTime) { + const readFreshes = this.readFreshes; + if (readFreshes) { + for (const [t, d] of readFreshes.entries()) { + if (lastUpdateTime >= t) { + readFreshes.delete(t); + d.resolve(lastUpdateTime); } - } else { - valueStore[key] = value; } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); } + this.valueDaoUpdatetime = lastUpdateTime; } + hold.resolve(); // 放行 emit } } @@ -224,6 +291,37 @@ export default class GMApi extends GM_Base { ); } + static async waitForFreshValueState(a: GMApi): Promise { + // 读取前没有任何 valueUpdate 的话,valueDaoUpdatetime 为 undefined + // valueDaoUpdatetime 需透过 valueUpdate 提供 (不要从其他途径影响页面缓存values) + if (!a.scriptRes) return; + let id = ""; + let d: Deferred | null = null; + if (!a.valueDaoUpdatetime) { + // 没有 setValues 直接 listValues / getValues 的话, valueDaoUpdatetime 为 undefined + // 要向 service_worker 发出请求,更新 页面缓存values,并触发 valueDaoUpdatetime 设置。 + id = generateValChangeId(); + d = deferred(); + valueChangePromiseMap.set(id, d.resolve); // 在 valueUpdate 里放行 Promise + } + const updatetimePromise = a.sendMessage("internalApiWaitForFreshValueState", [id]); + // 这里返回的 updatetime 是现时最新的 updatetime + const updatetime = (await Promise.all([updatetimePromise, d?.promise]))[0]; + if (updatetime && a.valueDaoUpdatetime && a.valueDaoUpdatetime < updatetime) { + // 未同步至最新状态,先等待 + // 由于 internalApiWaitForFreshValueState 返回的 updatetime 较新 + // 期待 pushToTab -> valueUpdate 的触发 + // 只要有 >=updatetime 的 valueUpdate, 就可以放行 + const readFreshes = (a.readFreshes ||= new Map>()); + let d = readFreshes.get(updatetime); + if (!d) { + readFreshes.set(updatetime, (d = deferred())); + } + await d.promise; + } + // valueDaoUpdatetime 最新,表示现在 缓存values 也是最新。可进行 listValues, getValues 等操作 + } + static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; const ret = a.scriptRes.value[key]; @@ -242,49 +340,45 @@ 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) { + static _GM_setValue(a: GMApi, promiseResolve: PromiseResolve, key: string, value: any) { if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); + const id = generateValChangeId(); + if (promiseResolve) { + valueChangePromiseMap.set(id, promiseResolve); } // 对object的value进行一次转化 if (value && typeof value === "object") { value = JSON.parse(JSON.stringify(value)); } + const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + a.extValueStoreCopy = { ...valueStore }; + } if (value === undefined) { - delete a.scriptRes.value[key]; + delete valueStore[key]; a.sendMessage("GM_setValue", [id, key]); } else { - a.scriptRes.value[key] = value; + valueStore[key] = value; a.sendMessage("GM_setValue", [id, key, value]); } return id; } - static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { + static _GM_setValues(a: GMApi, promiseResolve: PromiseResolve, values: TGMKeyValue) { if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); + const id = generateValChangeId(); + if (promiseResolve) { + valueChangePromiseMap.set(id, promiseResolve); } const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + a.extValueStoreCopy = { ...valueStore }; + } for (const [key, value] of Object.entries(values)) { let value_ = value; // 对object的value进行一次转化 @@ -339,10 +433,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; }); } @@ -386,9 +480,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); }); } @@ -403,7 +496,7 @@ export default class GMApi extends GM_Base { }); } - @GMContext.API() + @GMContext.API({ depend: ["GM_setValues"] }) public GM_deleteValues(keys: string[]) { if (!this.scriptRes) return; if (!Array.isArray(keys)) { @@ -418,7 +511,7 @@ export default class GMApi extends GM_Base { } // Asynchronous wrapper for GM.deleteValues - @GMContext.API({ depend: ["GM_deleteValues"] }) + @GMContext.API({ depend: ["GM_setValues"] }) public ["GM.deleteValues"](keys: string[]): Promise { if (!this.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { @@ -574,7 +667,7 @@ export default class GMApi extends GM_Base { // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 menuIdCounter: number | undefined; - // 菜单注冊累计器 - 用於穩定同一Tab不同frame之選項的單獨項目不合併狀態 + // 菜单注册累计器 - 用于稳定同一Tab不同frame之选项的单独项目不合并状态 // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 regMenuCounter: number | undefined; @@ -1420,4 +1513,4 @@ export default class GMApi extends GM_Base { export const { createGMBase } = GM_Base; // 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 -const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_xmlhttpRequest } = GMApi; +const { waitForFreshValueState, _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_xmlhttpRequest } = GMApi; diff --git a/src/app/service/content/inject.ts b/src/app/service/content/inject.ts index 916c101ad..105a66cf7 100644 --- a/src/app/service/content/inject.ts +++ b/src/app/service/content/inject.ts @@ -4,7 +4,7 @@ import { ExternalWhitelist } from "@App/app/const"; import { sendMessage } from "@Packages/message/client"; import type { ScriptExecutor } from "./script_executor"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; -import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateSendData } from "./types"; export class InjectRuntime { constructor( @@ -20,7 +20,7 @@ export class InjectRuntime { // 转发给脚本 this.scriptExecutor.emitEvent(data); }); - this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + this.server.on("runtime/valueUpdate", (data: ValueUpdateSendData) => { this.scriptExecutor.valueUpdate(data); }); diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index c353d314c..ae549c81c 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -2,7 +2,7 @@ import type { Message } from "@Packages/message/types"; import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import ExecScript from "./exec_script"; -import type { GMInfoEnv, ScriptFunc, PreScriptFunc, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ScriptFunc, PreScriptFunc, ValueUpdateSendData } from "./types"; import { addStyle, definePropertyListener } from "./utils"; export type ExecScriptEntry = { @@ -33,11 +33,13 @@ export class ScriptExecutor { } } - valueUpdate(data: ValueUpdateDataEncoded) { - const { uuid, storageName } = data; - for (const val of this.execMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + valueUpdate(sendData: ValueUpdateSendData) { + const { data, storageName } = sendData; + for (const [uuid, list] of Object.entries(data)) { + for (const val of this.execMap.values()) { + if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { + val.valueUpdate(storageName, uuid, list); + } } } } diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index e81f25d3f..b9adbf18d 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -31,7 +31,12 @@ export type ValueUpdateDataEncoded = { uuid: string; storageName: string; // 储存name sender: ValueUpdateSender; - valueUpdated: boolean; + updatetime: number; +}; + +export type ValueUpdateSendData = { + storageName: string; + data: Record; }; // gm_api.ts diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index c9c2a2b74..02ff77e79 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -1,4 +1,4 @@ -import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; +import type { SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; import type { InstallSource, SWScriptMenuItemOption, @@ -30,7 +30,7 @@ export type TEnableScript = { uuid: string; enable: boolean }; export type TScriptRunStatus = { uuid: string; runStatus: SCRIPT_RUN_STATUS }; -export type TScriptValueUpdate = { script: Script; valueUpdated: boolean }; +export type TScriptValueUpdate = { uuid: string; valueUpdated: boolean; status: SCRIPT_STATUS; isEarlyStart: boolean }; export type TScriptMenuRegister = { uuid: string; diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index cbf02fad4..f0da0bdc5 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -13,7 +13,7 @@ import { CronJob } from "cron"; import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; -import type { ValueUpdateData, ValueUpdateDataEncoded } from "../content/types"; +import type { ValueUpdateData, ValueUpdateSendData } from "../content/types"; import { getStorageName, getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; @@ -323,19 +323,24 @@ export class Runtime { return this.execScript(loadScript, true); } - valueUpdate(data: ValueUpdateDataEncoded) { - const dataNew = { ...data, entries: decodeMessage(data.entries) } as ValueUpdateData; - // 转发给脚本 - this.execScripts.forEach((val) => { - if (val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName) { - val.valueUpdate(data); - } - }); - // 更新crontabScripts中的脚本值 - for (const script of this.crontabSripts) { - if (script.uuid === data.uuid || getStorageName(script) === data.storageName) { - for (const [key, value, _oldValue] of dataNew.entries) { - script.value[key] = value; + valueUpdate(sendData: ValueUpdateSendData) { + const storageName = sendData.storageName; + for (const [uuid, list] of Object.entries(sendData.data)) { + // 转发给脚本 + this.execScripts.forEach((val) => { + if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { + val.valueUpdate(storageName, uuid, list); + } + }); + for (const data of list) { + const dataNew = { ...data, entries: decodeMessage(data.entries) } as ValueUpdateData; + // 更新crontabScripts中的脚本值 + for (const script of this.crontabSripts) { + if (script.uuid === uuid || getStorageName(script) === storageName) { + for (const [key, value, _oldValue] of dataNew.entries) { + script.value[key] = value; + } + } } } } diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index a0c3fbb18..7c95d3ca2 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -331,19 +331,37 @@ export default class GMApi { return true; } - @PermissionVerify.API({ link: ["GM_deleteValue"] }) + @PermissionVerify.API({ + default: true, + }) + async internalApiWaitForFreshValueState(request: GMApiRequest<[string]>, sender: IGetSender) { + const param = request.params; + if (param.length !== 1) { + throw new Error("there must be one parameter"); + } + const id = param[0]; + const valueSender = { + runFlag: request.runFlag, + tabId: sender.getSender()?.tab?.id || -1, + }; + const ret = await this.value.waitForFreshValueState(request.script.uuid, id, valueSender); + return ret; + } + + @PermissionVerify.API({ link: ["GM_deleteValue", "GM_deleteValues"] }) async GM_setValue(request: GMApiRequest<[string, string, any?]>, sender: IGetSender) { if (!request.params || request.params.length < 2) { throw new Error("param is failed"); } const [id, key, value] = request.params as [string, string, any]; - await this.value.setValue(request.script.uuid, id, key, value, { + const valueSender = { runFlag: request.runFlag, tabId: sender.getSender()?.tab?.id || -1, - }); + }; + await this.value.setValues(request.script.uuid, id, { [key]: value }, valueSender, false); } - @PermissionVerify.API({ link: ["GM_deleteValues"] }) + @PermissionVerify.API({ link: ["GM_deleteValue", "GM_deleteValues"] }) async GM_setValues(request: GMApiRequest<[string, TEncodedMessage]>, sender: IGetSender) { if (!request.params || request.params.length !== 2) { throw new Error("param is failed"); diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index d2bfffe55..4fde58f16 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -112,7 +112,7 @@ export default class PermissionVerify { } // 验证是否有权限 - async verify(request: GMApiRequest, api: ApiValue, sender: IGetSender): Promise { + async verify>(request: GMApiRequest, api: ApiValue, sender: IGetSender): Promise { const { alias, link, confirm } = api.param; if (api.param.default) { return true; @@ -130,7 +130,9 @@ export default class PermissionVerify { // 别名相等 (alias && alias.includes(grantName)) || // 关联包含 - (link && link.includes(grantName)) + (link && link.includes(grantName)) || + // 关联包含 (GM.XXXX) + (link && link.includes(grantName.replace(".", "_"))) ) { // 需要用户确认 let result = true; diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 0129dd99b..32815f2c7 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -437,15 +437,22 @@ export class RuntimeService { }); // 监听脚本值变更 - this.mq.subscribe("valueUpdate", async ({ script, valueUpdated }: TScriptValueUpdate) => { - if (valueUpdated) { - if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) { - // 如果是预加载脚本,需要更新脚本代码重新注册 - // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变 - await this.updateResourceOnScriptChange(script); + this.mq.subscribe( + "valueUpdate", + async ({ uuid, valueUpdated, status, isEarlyStart }: TScriptValueUpdate) => { + if (valueUpdated) { + if (status === SCRIPT_STATUS_ENABLE && isEarlyStart) { + // 如果是预加载脚本,需要更新脚本代码重新注册 + // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变 + const script = await this.scriptDAO.get(uuid); + // 因為從 scriptDAO 取了最新的。所以再確認一下吧。 + if (script && script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) { + await this.updateResourceOnScriptChange(script); + } + } } } - }); + ); if (chrome.extension.inIncognitoContext) { this.systemConfig.addListener("enable_script_incognito", async (enable) => { diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 685a1bfde..86c3d4bd7 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -13,10 +13,30 @@ import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; import { MessageQueue } from "@Packages/message/message_queue"; import type { ValueUpdateSender } from "../content/types"; -import { getStorageName } from "@App/pkg/utils/utils"; +import { deferred, getStorageName } from "@App/pkg/utils/utils"; +import { type TScriptValueUpdate } from "../queue"; +import { isEarlyStartScript } from "../content/utils"; +import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; initTestEnv(); +const nextTick = () => Promise.resolve(); +const flush = async () => { + await nextTick(); + await nextTick(); +}; + +const expectedValueUpdateEventEmit = (mockScript: Script, valueUpdated: boolean): TScriptValueUpdate => { + const valueUpdateEventEmit: TScriptValueUpdate = { + uuid: mockScript.uuid, + valueUpdated, + status: mockScript.status, + isEarlyStart: isEarlyStartScript(mockScript.metadata), + }; + return valueUpdateEventEmit; +}; + /** * ValueService.setValue 方法的单元测试 * @@ -104,7 +124,8 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); // 执行测试 - await valueService.setValue(mockScript.uuid, "testId-4021", key, value, mockSender); + await valueService.setValues(mockScript.uuid, "testId-4021", { [key]: value }, mockSender, false); + await flush(); // 验证结果 expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); @@ -113,20 +134,27 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1); expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( 1, + getStorageName(mockScript), expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4021", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + [mockScript.uuid]: [ + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(1).fill(expect.anything()), + }), + id: "testId-4021", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + updatetime: expect.any(Number), + uuid: mockScript.uuid, + }), + ], }) ); expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1); - expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: true }); + expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", expectedValueUpdateEventEmit(mockScript, true)); // 验证保存的数据结构 const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; @@ -151,7 +179,8 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); // 执行测试 - await valueService.setValue(mockScript.uuid, "testId-4022", key, value, mockSender); + await valueService.setValues(mockScript.uuid, "testId-4022", { [key]: value }, mockSender, false); + await flush(); // 验证结果 expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); @@ -160,20 +189,26 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1); expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( 1, + getStorageName(mockScript), expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4022", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + [mockScript.uuid]: [ + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(1).fill(expect.anything()), + }), + id: "testId-4022", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + uuid: mockScript.uuid, + }), + ], }) ); expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1); - expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: true }); + expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", expectedValueUpdateEventEmit(mockScript, true)); // 验证保存的数据结构 const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; @@ -207,7 +242,8 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); // 执行测试 - await valueService.setValue(mockScript.uuid, "testId-4023", key, newValue, mockSender); + await valueService.setValues(mockScript.uuid, "testId-4023", { [key]: newValue }, mockSender, false); + await flush(); // 验证结果 expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); @@ -216,23 +252,26 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1); expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( 1, + getStorageName(mockScript), expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4023", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + [mockScript.uuid]: [ + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(1).fill(expect.anything()), + }), + id: "testId-4023", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + uuid: mockScript.uuid, + }), + ], }) ); expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1); - expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { - script: mockScript, - valueUpdated: true, - }); + expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", expectedValueUpdateEventEmit(mockScript, true)); // 验证保存的数据被正确更新 const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; @@ -262,7 +301,8 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockValueDAO.get).mockResolvedValue(existingValueModel); // 执行测试 - await valueService.setValue(mockScript.uuid, "testId-4024", key, value, mockSender); + await valueService.setValues(mockScript.uuid, "testId-4024", { [key]: value }, mockSender, false); + await flush(); // 验证结果 - 不应该保存或发送更新 expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); @@ -271,20 +311,26 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1); expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( 1, + getStorageName(mockScript), expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4024", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: false, + [mockScript.uuid]: [ + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(0), + }), + id: "testId-4024", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + uuid: mockScript.uuid, + }), + ], }) ); // 值未改变 expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1); - expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: false }); // 值未改变 + expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", expectedValueUpdateEventEmit(mockScript, false)); // 值未改变 }); it("当设置值为undefined时应该删除该键", async () => { @@ -308,7 +354,8 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); // 执行测试 - 设置值为undefined - await valueService.setValue(mockScript.uuid, "testId-4025", key, undefined, mockSender); + await valueService.setValues(mockScript.uuid, "testId-4025", { [key]: undefined }, mockSender, false); + await flush(); // 验证结果 expect(mockValueDAO.save).toHaveBeenCalled(); @@ -330,8 +377,9 @@ describe("ValueService - setValue 方法测试", () => { // 执行测试并验证抛出错误 await expect( - valueService.setValue(nonExistentUuid, "testId-4026", "testKey", "testValue", mockSender) + valueService.setValues(nonExistentUuid, "testId-4026", { testKey: "testValue" }, mockSender, false) ).rejects.toThrow("script not found"); + await flush(); // 验证不会执行后续操作 expect(mockValueDAO.get).not.toHaveBeenCalled(); @@ -353,47 +401,66 @@ describe("ValueService - setValue 方法测试", () => { vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + expect(mockScriptDAO.get).toHaveBeenCalledTimes(0); + expect(mockValueDAO.save).toHaveBeenCalledTimes(0); + expect(valueService.pushValueToTab).toHaveBeenCalledTimes(0); + + const d = deferred(); + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${getStorageName(mockScript)}`, () => d.promise); // 并发执行两个setValue操作 - await Promise.all([ - valueService.setValue(mockScript.uuid, "testId-4041", key1, value1, mockSender), - valueService.setValue(mockScript.uuid, "testId-4042", key2, value2, mockSender), + const ret = Promise.all([ + valueService.setValues(mockScript.uuid, "testId-4041", { [key1]: value1 }, mockSender, false), + valueService.setValues(mockScript.uuid, "testId-4042", { [key2]: value2 }, mockSender, false), ]); + await flush(); + d.resolve(); + await flush(); + await ret; + await flush(); // 验证两个操作都被调用 expect(mockScriptDAO.get).toHaveBeenCalledTimes(2); - expect(mockValueDAO.save).toHaveBeenCalledTimes(2); - expect(valueService.pushValueToTab).toHaveBeenCalledTimes(2); + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + expect(mockValueDAO.save).toHaveBeenCalledTimes(1); + expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1); expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( 1, + getStorageName(mockScript), expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4041", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + [mockScript.uuid]: [ + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(1).fill(expect.anything()), + }), + id: "testId-4041", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + uuid: mockScript.uuid, + }), + expect.objectContaining({ + entries: expect.objectContaining({ + m: Array(1).fill(expect.anything()), + }), + id: "testId-4042", + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + storageName: getStorageName(mockScript), + uuid: mockScript.uuid, + }), + ], }) ); - expect(valueService.pushValueToTab).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4042", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, - }) + expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1); + expect(mockMessageQueue.emit).toHaveBeenNthCalledWith( + 1, + "valueUpdate", + expectedValueUpdateEventEmit(mockScript, true) ); - expect(mockMessageQueue.emit).toHaveBeenCalledTimes(2); - expect(mockMessageQueue.emit).toHaveBeenNthCalledWith(1, "valueUpdate", { script: mockScript, valueUpdated: true }); - expect(mockMessageQueue.emit).toHaveBeenNthCalledWith(2, "valueUpdate", { script: mockScript, valueUpdated: true }); }); }); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 7de284644..940371a2e 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -1,18 +1,32 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { type Script, ScriptDAO } from "@App/app/repo/scripts"; +import { type Script, type SCRIPT_STATUS, ScriptDAO } from "@App/app/repo/scripts"; import { type Value, ValueDAO } from "@App/app/repo/value"; import type { IGetSender, Group } from "@Packages/message/server"; import { type RuntimeService } from "./runtime"; import { type PopupService } from "./popup"; -import { getStorageName } from "@App/pkg/utils/utils"; -import type { ValueUpdateDataEncoded, ValueUpdateSender } from "../content/types"; +import { aNow, getStorageName } from "@App/pkg/utils/utils"; +import type { ValueUpdateDataEncoded, ValueUpdateSendData, ValueUpdateSender } from "../content/types"; import type { TScriptValueUpdate } from "../queue"; import { type TDeleteScript } from "../queue"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { encodeMessage } from "@App/pkg/utils/message_value"; +import { isEarlyStartScript } from "../content/utils"; + +type ValueUpdateTaskInfo = { + uuid: string; + id: string; + values: { + [key: string]: any; + }; + sender: ValueUpdateSender; + removeNotProvided: boolean; + status: SCRIPT_STATUS; + isEarlyStart: boolean; +}; +const valueUpdateTasks = new Map(); export class ValueService { logger: Logger; @@ -61,7 +75,10 @@ export class ValueService { return newValues; } - async setValue(uuid: string, id: string, key: string, value: any, sender: ValueUpdateSender) { + async waitForFreshValueState(uuid: string, id: string, valueSender: ValueUpdateSender): Promise { + if (id) { + await this.setValues(uuid, id, {}, valueSender, false); + } // 查询出脚本 const script = await this.scriptDAO.get(uuid); if (!script) { @@ -69,53 +86,30 @@ export class ValueService { } // 查询老的值 const storageName = getStorageName(script); - let oldValue; // 使用事务来保证数据一致性 const cacheKey = `${CACHE_KEY_SET_VALUE}${storageName}`; - const valueUpdated = await stackAsyncTask(cacheKey, async () => { - let valueModel: Value | undefined = await this.valueDAO.get(storageName); - if (!valueModel) { - const now = Date.now(); - valueModel = { - uuid: script.uuid, - storageName: storageName, - data: { [key]: value }, - createtime: now, - updatetime: now, - }; - } else { - let dataModel = valueModel.data; - // 值没有发生变化, 不进行操作 - oldValue = dataModel[key]; - if (oldValue === value) { - return false; - } - dataModel = { ...dataModel }; // 每次储存使用新参考 - if (value === undefined) { - delete dataModel[key]; - } else { - dataModel[key] = value; - } - valueModel.data = dataModel; // 每次储存使用新参考 - } - await this.valueDAO.save(storageName, valueModel); - return true; + const ret = await stackAsyncTask(cacheKey, async () => { + const valueModel: Value | undefined = await this.valueDAO.get(storageName); + // await this.valueDAO.save(storageName, valueModel); + return valueModel?.updatetime; }); - this.pushValueToTab({ - id, - entries: encodeMessage([[key, value, oldValue]]), - uuid, - storageName, - sender, - valueUpdated, - } as ValueUpdateDataEncoded); - // valueUpdate 消息用于 early script 的处理 - this.mq.emit("valueUpdate", { script, valueUpdated }); + return ret || 0; } // 推送值到tab - async pushValueToTab(sendData: T) { - const { storageName } = sendData; + async pushValueToTab>(storageName: string, data: T) { + const sendData: ValueUpdateSendData = { storageName, data }; + /* + --- data structure --- + { + storageName: XXXX + { + uuid1: data1 + uuid2: data2 + ... + } + } + */ chrome.tabs.query({}, (tabs) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -149,37 +143,46 @@ export class ValueService { ); } - // 批量设置 - async setValues( - uuid: string, - id: string, - values: { [key: string]: any }, - sender: ValueUpdateSender, - removeNotProvided: boolean - ) { - const script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - const storageName = getStorageName(script); - let oldValueRecord: { [key: string]: any } = {}; - const cacheKey = `${CACHE_KEY_SET_VALUE}${storageName}`; - const entries = [] as [string, any, any][]; - const _flag = await stackAsyncTask(cacheKey, async () => { - let valueModel: Value | undefined = await this.valueDAO.get(storageName); + async setValuesByStorageName(storageName: string) { + const taskListRef = valueUpdateTasks.get(storageName); + if (!taskListRef?.length) return; + let valueModel: Value | undefined = await this.valueDAO.get(storageName); + const taskList = taskListRef.slice(0); + taskListRef.length = 0; + valueUpdateTasks.delete(storageName); + // ------ 读取 & 更新 ------ + let updatetime = 0; + const listRetToTab: Record = {}; + let valueModelUpdated = false; + let hasValueUpdated = false; + for (const task of taskList) { + const entries = [] as [string, any, any][]; + const { uuid, values, removeNotProvided } = task; + let oldValueRecord: { [key: string]: any } = {}; + const now = aNow(); // 保证严格递增 + let newData; if (!valueModel) { - const now = Date.now(); + const dataModel: { [key: string]: any } = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + dataModel[key] = value; + entries.push([key, value, undefined]); + } + } + // 即使是空 dataModel 也进行更新 + // 由于没entries, valueUpdated 是 false, 但 valueDAO 会有一个空的 valueModel 记录 updatetime valueModel = { - uuid: script.uuid, + uuid: uuid, storageName: storageName, - data: values, + data: dataModel, createtime: now, updatetime: now, }; + newData = dataModel; } else { let changed = false; - let dataModel = (oldValueRecord = valueModel.data); - dataModel = { ...dataModel }; // 每次储存使用新参考 + oldValueRecord = valueModel.data; + const dataModel = { ...oldValueRecord }; // 每次储存使用新参考 for (const [key, value] of Object.entries(values)) { const oldValue = dataModel[key]; if (oldValue === value) continue; @@ -203,24 +206,94 @@ export class ValueService { } } } - if (!changed) return false; - valueModel.data = dataModel; // 每次储存使用新参考 + if (changed) { + newData = dataModel; + } } - await this.valueDAO.save(storageName, valueModel); - return true; - }); + if (newData) { + valueModel.updatetime = now; + valueModel.data = newData; // 每次储存使用新参考 + valueModelUpdated = true; + } + updatetime = valueModel.updatetime; + + { + const { uuid, id, sender } = task; + let list = listRetToTab[uuid]; + if (!list) { + listRetToTab[uuid] = list = []; + } + const valueUpdated = entries.length > 0; + if (valueUpdated) hasValueUpdated = true; + list.push({ + id, + entries: encodeMessage(entries), + uuid, + storageName, + sender, + valueUpdated, + updatetime, + } as ValueUpdateDataEncoded); + } + } + if (valueModelUpdated) { + await this.valueDAO.save(storageName, valueModel!); + } + // ------ 推送 ------ // 推送到所有加载了本脚本的tab中 - const valueUpdated = entries.length > 0; - this.pushValueToTab({ - id, - entries: encodeMessage(entries), - uuid, - storageName, - sender, - valueUpdated, - } as ValueUpdateDataEncoded); - // valueUpdate 消息用于 early script 的处理 - this.mq.emit("valueUpdate", { script, valueUpdated }); + this.pushValueToTab(storageName, listRetToTab); + // 针对各脚本,只需要发送一次最后的结果 + const valueUpdateEmits = new Map(); + for (const task of taskList) { + const { uuid, status, isEarlyStart } = task; + valueUpdateEmits.set(uuid, { status, isEarlyStart }); + } + for (const [uuid, { status, isEarlyStart }] of valueUpdateEmits.entries()) { + // valueUpdate 消息用于 early script 的处理 + // 由于经过 await, 此处的 status 和 isEarlyStart 只供参考,应在接收端检查最新设置值 + this.mq.emit("valueUpdate", { + uuid, + valueUpdated: hasValueUpdated, + status, + isEarlyStart, + }); + } + } + + // 批量设置 + async setValues( + uuid: string, + id: string, + values: { [key: string]: any }, + sender: ValueUpdateSender, + removeNotProvided: boolean + ): Promise { + // stackAsyncTask 确保 setValues的 taskList阵列新增次序正确 + let storageName: string; + let cacheKey: string; + await stackAsyncTask("valueChangeOnSequence", async () => { + const script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + storageName = getStorageName(script); + cacheKey = `${CACHE_KEY_SET_VALUE}${storageName}`; + let taskList = valueUpdateTasks.get(storageName); + if (!taskList) { + valueUpdateTasks.set(storageName, (taskList = [])); + } + taskList.push({ + uuid, + id, + values, + sender, + removeNotProvided, + status: script.status, + isEarlyStart: isEarlyStartScript(script.metadata), + }); + }); + // valueDAO 次序依 storageName + await stackAsyncTask(cacheKey!, () => this.setValuesByStorageName(storageName!)); } setScriptValue({ uuid, key, value }: { uuid: string; key: string; value: any }, _sender: IGetSender) { @@ -228,7 +301,7 @@ export class ValueService { runFlag: "user", tabId: -2, }; - return this.setValue(uuid, "", key, value, valueSender); + return this.setValues(uuid, "", { [key]: value }, valueSender, false); } setScriptValues({ uuid, values }: { uuid: string; values: { [key: string]: any } }, _sender: IGetSender) { diff --git a/src/pkg/utils/async_queue.test.ts b/src/pkg/utils/async_queue.test.ts index 01675eafb..67730708a 100644 --- a/src/pkg/utils/async_queue.test.ts +++ b/src/pkg/utils/async_queue.test.ts @@ -1,22 +1,12 @@ // async_queue.test.ts import { describe, it, expect } from "vitest"; import { stackAsyncTask } from "./async_queue"; +import { deferred } from "@App/pkg/utils/utils"; /* ==================== 工具函数 ==================== */ const generateKey = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; -/** 手动控制的 Promise(用于阻塞) */ -const deferred = () => { - let resolve!: (v: T | PromiseLike) => void; - let reject!: (e?: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - const nextTick = () => Promise.resolve(); /** 强制执行所有已入队微任务与 then 链 */ const flush = async () => { diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 4aeb59197..0c9b0b3b6 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -1,9 +1,32 @@ import { describe, expect, it } from "vitest"; -import { checkSilenceUpdate, cleanFileName, stringMatching } from "./utils"; +import { aNow, checkSilenceUpdate, cleanFileName, stringMatching } from "./utils"; import { ltever, versionCompare } from "@App/pkg/utils/semver"; import { nextTime } from "./cron"; import dayjs from "dayjs"; +describe.concurrent("aNow", () => { + it.sequential("aNow is Strictly Increasing", () => { + const p1 = [aNow(), aNow(), aNow(), aNow(), aNow(), aNow()]; + expect(p1[0]).lessThan(p1[1]); + expect(p1[1]).lessThan(p1[2]); + expect(p1[2]).lessThan(p1[3]); + expect(p1[3]).lessThan(p1[4]); + expect(p1[4]).lessThan(p1[5]); + const p2 = [...p1].sort(); + expect(p1).toEqual(p2); + }); + it.sequential("t1 > t2 (busy) and t3 = t4 (idle)", async () => { + const _p1 = [aNow(), aNow(), aNow(), aNow(), aNow(), aNow()]; + const t1 = aNow(); + const t2 = Date.now(); + expect(t1).greaterThan(t2); + await new Promise((resolve) => setTimeout(resolve, 10)); + const t3 = aNow(); + const t4 = Date.now(); + expect(t3).toEqual(t4); + }); +}); + describe.concurrent("nextTime", () => { const date = new Date(1737275107000); it.concurrent("每分钟表达式", () => { diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index d4b62df4b..970e999a4 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -11,6 +11,34 @@ export function randomMessageFlag(): string { return `-${Date.now().toString(36)}.${randNum(8e11, 2e12).toString(36)}`; } +let prevNow = 0; +/** + * accumulated "now". + * 用 aNow 取得的现在时间能保证严格递增 + */ +export const aNow = () => { + let now = Date.now(); + if (prevNow >= now) now = prevNow + 0.0009765625; // 2^-10 + prevNow = now; + return now; +}; + +export type Deferred = { + promise: Promise; + resolve: (v: T | PromiseLike) => void; + reject: (e?: any) => void; +}; + +export const deferred = (): Deferred => { + let resolve!: (v: T | PromiseLike) => void; + let reject!: (e?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + export function isFirefox() { //@ts-ignore return typeof mozInnerScreenX !== "undefined";