From 4dc6613a4b45003f9a58c336f981a44e5560e4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 20 Oct 2026 14:32:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20GM=5Finfo=20=E6=96=B0=E5=A2=9E=20us?= =?UTF-8?q?erLanguage=EF=BC=8C=E6=9A=B4=E9=9C=B2=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E7=8C=AB=E5=BD=93=E5=89=8D=E7=95=8C=E9=9D=A2=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=20(#1018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/exec_script.test.ts | 11 +++++++ src/app/service/content/exec_script.ts | 1 + src/app/service/content/exec_warp.test.ts | 13 +++++--- src/app/service/content/exec_warp.ts | 8 ++++- src/app/service/content/gm_api/gm_api.test.ts | 1 + src/app/service/content/gm_api/gm_info.ts | 1 + src/app/service/content/script_executor.ts | 2 ++ src/app/service/content/types.ts | 1 + src/app/service/sandbox/runtime.test.ts | 14 ++++++++- src/app/service/sandbox/runtime.ts | 8 +++-- .../service/service_worker/runtime.test.ts | 30 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 4 ++- src/template/scriptcat.d.tpl | 1 + src/types/scriptcat.d.ts | 5 ++++ src/types/scriptcat.zh-CN.d.ts | 5 ++++ 15 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..f70643e6f 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -16,6 +16,7 @@ const envInfo: GMInfoEnv = { platform: "", }, isIncognito: false, + userLanguage: "ja-JP", }; function makeScript(overrides: Partial = {}): ScriptLoadInfo { @@ -68,6 +69,16 @@ describe.concurrent("GM_info", () => { expect(ret.GM_info.script.version).toEqual("1.0.0"); expect(ret._this).not.toEqual(global); }); + it.concurrent("userLanguage 取自环境信息中的脚本猫界面语言", async () => { + const { exec } = makeExec("return GM_info.userLanguage;", ["none"]); + expect(await exec.exec()).toEqual("ja-JP"); + }); + it.concurrent("早期脚本收到页面环境后更新 userLanguage", async () => { + const { exec } = makeExec("return GM_info;", ["none"]); + const gmInfo = await exec.exec(); + exec.updateEarlyScriptGMInfo({ ...envInfo, userLanguage: "de-DE" }); + expect(gmInfo.userLanguage).toEqual("de-DE"); + }); }); describe.concurrent("unsafeWindow", () => { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..ae8ee47c7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -104,6 +104,7 @@ export default class ExecScript { GM_info.isIncognito = envInfo.isIncognito; GM_info.sandboxMode = envInfo.sandboxMode; GM_info.userAgentData = envInfo.userAgentData; + GM_info.userLanguage = envInfo.userLanguage; } stop() { diff --git a/src/app/service/content/exec_warp.test.ts b/src/app/service/content/exec_warp.test.ts index 83dfffb00..5fc9fc10b 100644 --- a/src/app/service/content/exec_warp.test.ts +++ b/src/app/service/content/exec_warp.test.ts @@ -23,7 +23,7 @@ const getGMInfo = (exec: BgExecScriptWarp) => (exec as any).named?.GM_info; describe("BgExecScriptWarp envInfo 注入", () => { it("extensionEnv 为 undefined 时使用默认值", () => { - const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, undefined); + const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, undefined, ""); const gmInfo = getGMInfo(exec); expect(gmInfo.isIncognito).toBe(false); expect(gmInfo.userAgentData).toEqual({ @@ -34,9 +34,14 @@ describe("BgExecScriptWarp envInfo 注入", () => { expect(gmInfo.sandboxMode).toBe("raw"); }); + it("userLanguage 使用沙盒当前的脚本猫界面语言", () => { + const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, undefined, "zh-TW"); + expect(getGMInfo(exec).userLanguage).toBe("zh-TW"); + }); + it("inIncognitoContext=true 时覆盖 isIncognito 为 true", () => { const extensionEnv: TExtensionEnv = { inIncognitoContext: true }; - const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv); + const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv, ""); const gmInfo = getGMInfo(exec); expect(gmInfo.isIncognito).toBe(true); // 没传 userAgentData,沿用默认空值 @@ -58,7 +63,7 @@ describe("BgExecScriptWarp envInfo 注入", () => { bitness: "64", } as any, }; - const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv); + const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv, ""); const gmInfo = getGMInfo(exec); expect(gmInfo.isIncognito).toBe(false); expect(gmInfo.userAgentData).toEqual({ @@ -75,7 +80,7 @@ describe("BgExecScriptWarp envInfo 注入", () => { inIncognitoContext: true, userAgentData: null, }; - const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv); + const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv, ""); const gmInfo = getGMInfo(exec); expect(gmInfo.isIncognito).toBe(true); expect(gmInfo.userAgentData).toEqual({ diff --git a/src/app/service/content/exec_warp.ts b/src/app/service/content/exec_warp.ts index 4363d6803..9f131ed1e 100644 --- a/src/app/service/content/exec_warp.ts +++ b/src/app/service/content/exec_warp.ts @@ -24,7 +24,12 @@ export class BgExecScriptWarp extends ExecScript { setInterval: Map; - constructor(scriptRes: ScriptLoadInfo, message: Message, extensionEnv: TExtensionEnv | undefined) { + constructor( + scriptRes: ScriptLoadInfo, + message: Message, + extensionEnv: TExtensionEnv | undefined, + userLanguage: string + ) { const thisContext: { [key: string]: any } = {}; const setTimeout = new Map(); const setInterval = new Map(); @@ -73,6 +78,7 @@ export class BgExecScriptWarp extends ExecScript { platform: "", }, isIncognito: false, + userLanguage, }; const { inIncognitoContext, userAgentData } = extensionEnv || {}; if (typeof inIncognitoContext === "boolean") envInfo.isIncognito = inIncognitoContext; 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 cf926aed6..d09336d0a 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -30,6 +30,7 @@ const envInfo: GMInfoEnv = { platform: "", }, isIncognito: false, + userLanguage: "en-US", }; const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ diff --git a/src/app/service/content/gm_api/gm_info.ts b/src/app/service/content/gm_api/gm_info.ts index 44ca42d25..27e818800 100644 --- a/src/app/service/content/gm_api/gm_info.ts +++ b/src/app/service/content/gm_api/gm_info.ts @@ -24,6 +24,7 @@ export function evaluateGMInfo(envInfo: GMInfoEnv, script: TScriptInfo) { scriptWillUpdate: !!script.checkUpdate, scriptHandler: "ScriptCat", userAgentData: envInfo.userAgentData, + userLanguage: envInfo.userLanguage, // "" => null scriptUpdateURL: script.downloadUrl || null, scriptMetaStr: script.metadataStr, diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 7bbcd9bb3..b4ccc0e44 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -26,6 +26,8 @@ export const initEnvInfo: GMInfoEnv = { /** isIncognito - inject/content 环境下透過 scripting 环境判断 */ /** 使用者可透过 「 await navigator.storage.persisted() 」来判断,但ScriptCat不会主动执行此代码来判断 */ isIncognito: false, + /** userLanguage - 页面环境未到达前未知,由 updateEarlyScriptGMInfo 补上 */ + userLanguage: "", } satisfies GMInfoEnv; // 脚本执行器 diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..87962e4a4 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -50,4 +50,5 @@ export interface GMInfoEnv { userAgentData: typeof GM_info.userAgentData; sandboxMode: typeof GM_info.sandboxMode; // 目前固定为 "raw",预留 isIncognito: typeof GM_info.isIncognito; + userLanguage: typeof GM_info.userLanguage; } diff --git a/src/app/service/sandbox/runtime.test.ts b/src/app/service/sandbox/runtime.test.ts index 21cf99929..0e0d0e381 100644 --- a/src/app/service/sandbox/runtime.test.ts +++ b/src/app/service/sandbox/runtime.test.ts @@ -30,8 +30,11 @@ vi.mock("../offscreen/client", () => ({ })); import { Runtime } from "./runtime"; +import { initLanguage } from "@App/locales/locales"; initTestEnv(); +// 生产中 sandbox init() 会先 initLanguage(),setSandboxLanguage 依赖已初始化的 i18n +initLanguage(); const buildScript = (runIn?: string, type: SCRIPT_TYPE = SCRIPT_TYPE_BACKGROUND): ScriptLoadInfo => ({ @@ -115,6 +118,13 @@ describe("Runtime.execScript run-in 过滤", () => { expect(BgExecScriptWarpCtor).toHaveBeenCalledTimes(1); }); + it("执行时向 BgExecScriptWarp 传入最近一次同步的界面语言", async () => { + const runtime = setup({ inIncognitoContext: false }); + runtime.setSandboxLanguage("ko-KR"); + await runtime.execScript(buildScript()); + expect(BgExecScriptWarpCtor.mock.calls[0][3]).toBe("ko-KR"); + }); + it("extensionEnv 为 undefined 时,过滤被跳过照常执行(fail-open)", async () => { // 当前实现:拿不到 incognito 状态时不做过滤,避免静默丢失任务 const runtime = setup(undefined); @@ -135,9 +145,10 @@ describe("Runtime.executeSkillScript 执行环境", () => { vi.useRealTimers(); }); - it("执行 skill 脚本时向 BgExecScriptWarp 传入扩展环境", async () => { + it("执行 skill 脚本时向 BgExecScriptWarp 传入扩展环境与界面语言", async () => { const extensionEnv: TExtensionEnv = { inIncognitoContext: true }; const runtime = setup(extensionEnv); + runtime.setSandboxLanguage("pt-BR"); await runtime.executeSkillScript({ uuid: "skill-uuid", @@ -149,5 +160,6 @@ describe("Runtime.executeSkillScript 执行环境", () => { expect(BgExecScriptWarpCtor).toHaveBeenCalledTimes(1); expect(BgExecScriptWarpCtor.mock.calls[0][2]).toBe(extensionEnv); + expect(BgExecScriptWarpCtor.mock.calls[0][3]).toBe("pt-BR"); }); }); diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 49e215384..f51b64f10 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -40,6 +40,9 @@ export class Runtime { retryTime: number; }[] = []; + // 供 GM_info.userLanguage 使用,由 SW 经 setSandboxLanguage 同步;收到之前未知,为空串 + userLanguage = ""; + constructor( private windowMessage: WindowMessage, private api: Server, @@ -190,7 +193,7 @@ export class Runtime { } } - const exec = new BgExecScriptWarp(script, this.windowMessage, extensionEnv); + const exec = new BgExecScriptWarp(script, this.windowMessage, extensionEnv, this.userLanguage); this.execScriptMap.set(script.uuid, exec); proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_RUNNING }); // 修改掉脚本掉最后运行时间, 数据库也需要修改 @@ -372,6 +375,7 @@ export class Runtime { } setSandboxLanguage(lang: string) { + this.userLanguage = lang; changeLanguage(lang); } @@ -434,7 +438,7 @@ export class Runtime { // 使用 BgExecScriptWarp 执行,它会自动构建 setTimeout/setInterval 等 const extensionEnv = await this.extensionEnvAsync; - const exec = new BgExecScriptWarp(scriptLoadInfo, this.windowMessage, extensionEnv); + const exec = new BgExecScriptWarp(scriptLoadInfo, this.windowMessage, extensionEnv, this.userLanguage); // 通过 sandboxContext 注入 args(BgExecScriptWarp 通过 globalInjection 注入了 setTimeout 等, // sandboxContext 已经包含了这些,再追加 args 即可) if ((exec as any).sandboxContext) { diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 4d7a3fb69..2dbf717f9 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -30,6 +30,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 let runtime: RuntimeService; let mockSystemConfig: { getBlacklist: MockedFunction<() => string>; + getLanguage: MockedFunction<() => Promise>; }; let mockScriptService: { buildScriptRunResource: MockedFunction<(script: Script, scriptFlag?: string) => ScriptRunResource>; @@ -73,6 +74,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 const createRuntimeTestContext = () => { const localMockSystemConfig = { getBlacklist: vi.fn().mockReturnValue(""), + getLanguage: vi.fn().mockResolvedValue("en-US"), }; const localMockScriptService = { @@ -1101,6 +1103,17 @@ describe("getScriptsForTab 附加边界场景", () => { expect(result?.envInfo.isIncognito).toBe(true); }); + it("返回的 GM_info 环境携带脚本猫当前界面语言", async () => { + const { runtime, script, scriptRes, mockScriptDAO, mockSystemConfig } = createFullContext(); + mockSystemConfig.getLanguage.mockResolvedValue("ja-JP"); + await runtime.applyScriptMatchInfo(scriptRes); + (mockScriptDAO as any).gets = vi.fn().mockResolvedValue([script]); + + const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: 12, frameId: 0 }); + + expect(result?.envInfo.userLanguage).toBe("ja-JP"); + }); + it("compiledResource 不存在时应调用 buildAndSaveCompiledResourceFromScript 重新构建", async () => { const { runtime, scriptRes, compiledResource, mockCompiledResourceDAO } = createFullContext(); await runtime.applyScriptMatchInfo(scriptRes); @@ -1255,6 +1268,23 @@ describe("sandbox verified 初始化重放", () => { expect(mockSystemConfig.addListener).toHaveBeenCalledTimes(1); expect(mockSystemConfig.addListener).toHaveBeenCalledWith("language", expect.any(Function)); }); + + it("先下发界面语言再启用后台脚本,保证启动即运行的脚本读到的 GM_info.userLanguage 已就绪", async () => { + const { runtime, mockScriptDAO } = _createRuntimeContext(); + mockScriptDAO.all.mockResolvedValue([ + _createMockScript({ uuid: "background-script", type: SCRIPT_TYPE_BACKGROUND, status: SCRIPT_STATUS_ENABLE }), + ]); + const publish = (runtime.mq as unknown as { publish: ReturnType }).publish; + publish.mockClear(); + + await ( + runtime as unknown as { handlePreparationOffscreen(data: { verified: boolean }): Promise } + ).handlePreparationOffscreen({ verified: true }); + + const topics = publish.mock.calls.map(([topic]) => topic); + expect(topics.indexOf("setSandboxLanguage")).toBeGreaterThanOrEqual(0); + expect(topics.indexOf("setSandboxLanguage")).toBeLessThan(topics.indexOf("enableScripts")); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 5dfa33d01..9135e75d9 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1687,6 +1687,7 @@ export class RuntimeService { sandboxMode: "raw", isIncognito: incognito, userAgentData: this.userAgentData ?? undefined, + userLanguage: await this.systemConfig.getLanguage(), } as GMInfoEnv, scriptmenus, } satisfies TScriptsForTab; @@ -1706,10 +1707,11 @@ export class RuntimeService { enable: script.status === SCRIPT_STATUS_ENABLE, }); } + // 语言须先于 enableScripts 到达沙盒:后台脚本启动时即读取 GM_info.userLanguage + this.mq.publish("setSandboxLanguage", language); if (scripts.length > 0) { this.mq.publish("enableScripts", scripts); } - this.mq.publish("setSandboxLanguage", language); this.systemConfig.addListener("language", (lng) => { this.mq.publish("setSandboxLanguage", lng); }); diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..4bd7b3d3b 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -32,6 +32,7 @@ declare const GM_info: { userConfig?: UserConfig; userConfigStr?: string; isIncognito: boolean; + userLanguage: string; // ScriptCat 界面语言,如 "zh-CN";尚未获知时为 "" sandboxMode: "raw"; // "js" | "raw" | "none"; userAgentData: { brands?: { diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index f0d1214dc..4cd206151 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -46,6 +46,11 @@ declare const GM_info: { userConfigStr?: string; /** Whether running in an incognito/private window. */ isIncognito: boolean; + /** + * ScriptCat's current interface language, e.g. `"en-US"`, `"zh-CN"`, `"ja-JP"`. + * Captured when the script starts; empty string while still unknown (e.g. read synchronously by an early-start script). + */ + userLanguage: string; /** Sandbox mode (ScriptCat always uses `"raw"`). */ sandboxMode: "raw"; userAgentData: { diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 1d1eb8507..6f5ffac7f 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -51,6 +51,11 @@ declare const GM_info: { userConfigStr?: string; /** 是否在隐私/无痕窗口中运行。 */ isIncognito: boolean; + /** + * ScriptCat 当前界面语言,如 `"en-US"`、`"zh-CN"`、`"ja-JP"`。 + * 在脚本启动时取值;尚未获知时为空字符串(例如早期脚本同步读取时)。 + */ + userLanguage: string; /** 沙箱模式(ScriptCat 始终使用 `"raw"`)。 */ sandboxMode: "raw"; userAgentData: {