diff --git a/docs/develop.md b/docs/develop.md index bff7539ef..aed480f9e 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -51,6 +51,28 @@ Use strict TypeScript, React JSX runtime, 2-space indentation, semicolons, doubl - UI default English (global users). - Template literals: `${i}`, not `${i.toString()}`. +### Browser-specific behavior + +ScriptCat's userscript-facing contract should remain stable across supported browsers. Do not add browser-specific +configuration knobs merely because one browser exposes an extra setting; browser differences belong in the adapter +layer and should not become a second, browser-shaped product design. + +When a browser API or option is inherently browser-specific and a userscript uses it: + +- Preserve the common behavior where possible, but never silently pass an unsupported option, silently ignore it, or + make the behavior look equivalent when it is not. +- Emit a warning in the relevant browser DevTools console that names the browser-specific option and states what the + current browser actually does (for example, supported, ignored, or unavailable). Use the existing logger and + include the userscript identity so the warning is actionable. +- Deduplicate that warning by userscript identity for the lifetime of the owning runtime; repeated API calls from the + same userscript must not flood DevTools, while different userscripts must be warned independently. +- Add tests for every browser branch and for the per-userscript warning boundary. The tests must also prove that the + underlying API behavior remains correct after the diagnostic is emitted. + +This policy applies to future browser-specific API options as well as existing compatibility shims. The implementation +may differ by context, but the user-visible rule is the same: browser capability differences are explicit diagnostics, +not silently exposed configuration surfaces. + ## UI React 19 + shadcn/ui (Radix UI primitives, "new-york" style) + Tailwind CSS v4 + React Router. Pages in 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..3be673290 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -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 GMApi from "./gm_api"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -1324,3 +1325,28 @@ describe("@grant CAT.agent.dom", () => { expect(ret.hasCat).toEqual(false); }); }); + +describe.concurrent("GM_cookie 参数校验(firstPartyDomain)", () => { + const makeA = () => ({ sendMessage: vi.fn().mockResolvedValue([]) }) as any; + + it.concurrent("firstPartyDomain 非字符串时应回传 Invalid Argument Type 错误", async () => { + const a = makeA(); + const done = vi.fn(); + (GMApi as any)._GM_cookie(a, "list", { url: "https://example.com", firstPartyDomain: 123 }, done); + await Promise.resolve(); + expect(done).toHaveBeenCalledWith(undefined, expect.any(Error)); + expect(done.mock.calls[0][1].message).toEqual("Invalid Argument Type"); + expect(a.sendMessage).not.toHaveBeenCalled(); + }); + + it.concurrent("firstPartyDomain 为合法字符串时应正常透传给 sendMessage", async () => { + const a = makeA(); + const done = vi.fn(); + (GMApi as any)._GM_cookie(a, "list", { url: "https://example.com", firstPartyDomain: "example.com" }, done); + await Promise.resolve(); + expect(a.sendMessage).toHaveBeenCalledWith("GM_cookie", [ + "list", + expect.objectContaining({ firstPartyDomain: "example.com" }), + ]); + }); +}); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..ca9e037b1 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -560,6 +560,7 @@ export default class GMApi extends GM_Base { typeof (details || false) !== "object" || typeof (details.domain ?? "") !== "string" || typeof (details.expirationDate ?? 0) !== "number" || + typeof (details.firstPartyDomain ?? "") !== "string" || typeof (details.httpOnly ?? false) !== "boolean" || typeof (details.name ?? "") !== "string" || typeof (details.partitionKey ?? null) !== "object" || diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 7617e3fa6..e54d5d205 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; import GMApi, { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api"; @@ -210,3 +210,191 @@ describe.concurrent("native GM_download 的 @connect 校验(verifyXhrConnect await expect(xhrConfirm(req, makeConnSender(), makeGmApi())).rejects.toThrow(/not a part of the @connect list/); }); }); + +describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation)", () => { + const makeCookieReq = ( + detail: GMTypes.CookieDetails, + action: string + ): GMApiRequest<[string, GMTypes.CookieDetails]> => + ({ + uuid: "uuid-test", + api: "GM_cookie", + runFlag: "run", + params: [action, detail], + script: { uuid: "uuid-test", name: "测试脚本", metadata: {} }, + }) as unknown as GMApiRequest<[string, GMTypes.CookieDetails]>; + + // tabId 为 -1 以跳过 chrome.cookies.getAllCookieStores 查询 + const cookieSender = makeSender("https://example.com/page") as unknown as IGetSender & { + getExtMessageSender: () => ExtMessageSender; + }; + (cookieSender as any).getExtMessageSender = () => ({ tabId: -1 }) as ExtMessageSender; + + // chrome.cookies.getAll/set/remove 是重载函数(Promise 或 callback 两种签名),vi.spyOn 只能推断出最后一个重载(callback/void); + // 这里narrowing 到实际调用的 Promise 签名,避免 mockResolvedValue 类型报错 + const cookiesApi = chrome.cookies as unknown as { + getAll(details: chrome.cookies.GetAllDetails): Promise; + set(details: chrome.cookies.SetDetails): Promise; + remove(details: chrome.cookies.CookieDetails): Promise; + }; + const makeCookieGMApi = () => ({ logger: { warn: vi.fn() }, warnedFirstPartyDomainScriptUuids: new Set() }); + + afterEach(() => { + vi.restoreAllMocks(); + // 仅还原本 describe 块自行 stub 的 mozInnerScreenX,避免影响 setup 文件里的全局 chrome stub + delete (globalThis as any).mozInnerScreenX; + }); + + it("每个脚本首次使用 firstPartyDomain 时只在开发者工具警告一次", async () => { + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const warn = vi.fn(); + const gmApi = { logger: { warn }, warnedFirstPartyDomainScriptUuids: new Set() }; + const req = makeCookieReq({ url: "https://example.com", firstPartyDomain: "example.com" }, "list"); + const otherScriptReq = { + ...req, + uuid: "other-uuid-test", + script: { ...req.script, uuid: "other-uuid-test", name: "另一个测试脚本" }, + }; + + await (GMApi.prototype as any).GM_cookie.call(gmApi, req, cookieSender); + await (GMApi.prototype as any).GM_cookie.call(gmApi, req, cookieSender); + await (GMApi.prototype as any).GM_cookie.call(gmApi, otherScriptReq, cookieSender); + + expect(getAllSpy).toHaveBeenCalledTimes(3); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenNthCalledWith( + 1, + "GM_cookie firstPartyDomain is only supported by Firefox and is ignored in this browser.", + { uuid: "uuid-test", name: "测试脚本", component: "GM_cookie" } + ); + expect(warn).toHaveBeenNthCalledWith( + 2, + "GM_cookie firstPartyDomain is only supported by Firefox and is ignored in this browser.", + { uuid: "other-uuid-test", name: "另一个测试脚本", component: "GM_cookie" } + ); + }); + + it("Firefox 环境下会提示 firstPartyDomain 的跨浏览器兼容性", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const warn = vi.fn(); + const gmApi = { logger: { warn }, warnedFirstPartyDomainScriptUuids: new Set() }; + const req = makeCookieReq({ url: "https://example.com", firstPartyDomain: "example.com" }, "list"); + + await (GMApi.prototype as any).GM_cookie.call(gmApi, req, cookieSender); + + expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "GM_cookie firstPartyDomain is Firefox-specific and may behave differently in other browsers.", + { uuid: "uuid-test", name: "测试脚本", component: "GM_cookie" } + ); + }); + + it("非 Firefox 环境下,firstPartyDomain 不会传递给 chrome.cookies.getAll(Chrome 会拒绝未知参数)", async () => { + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const req = makeCookieReq({ url: "https://example.com", firstPartyDomain: "example.com" }, "list"); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); + expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); + }); + + it("Firefox 环境下,firstPartyDomain 会被裁剪空白后传递给 chrome.cookies.getAll", async () => { + vi.stubGlobal("mozInnerScreenX", 0); // 模拟 isFirefox() 为 true + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const req = makeCookieReq({ url: "https://example.com", firstPartyDomain: " example.com " }, "list"); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); + expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); + }); + + it("Firefox 环境下,set 操作也会传递 firstPartyDomain 给 chrome.cookies.set", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const setSpy = vi.spyOn(cookiesApi, "set").mockResolvedValue({} as chrome.cookies.Cookie); + const req = makeCookieReq( + { url: "https://example.com", name: "n", value: "v", firstPartyDomain: "example.com" }, + "set" + ); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); + expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); + }); + + it("Firefox 环境下,list 未提供 firstPartyDomain 时补字面量 null(Firefox 的 getAll 专门区分「完全没有该 key」与「该 key 为 null」,只有前者在 FPI 开启时报错,见 violentmonkey#746)", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const req = makeCookieReq({ url: "https://example.com" }, "list"); + await (GMApi.prototype as any).GM_cookie.call({}, req, cookieSender); + expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBeNull(); + }); + + it("Firefox 环境下,delete 未提供 firstPartyDomain 时直接省略该字段(remove 对 null 与未提供一视同仁,补 null 无意义)", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const removeSpy = vi.spyOn(cookiesApi, "remove").mockResolvedValue({} as chrome.cookies.CookieDetails); + const req = makeCookieReq({ url: "https://example.com", name: "n" }, "delete"); + await (GMApi.prototype as any).GM_cookie.call({}, req, cookieSender); + expect(removeSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); + }); + + it("Firefox 环境下,set/delete 未提供 firstPartyDomain 且 FPI 开启时,Firefox 的拒绝会原样传播给调用方,而不是被吞掉", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const fpiError = new Error( + "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set." + ); + vi.spyOn(cookiesApi, "set").mockRejectedValue(fpiError); + vi.spyOn(cookiesApi, "remove").mockRejectedValue(fpiError); + + await expect( + (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com", name: "n", value: "v" }, "set"), + cookieSender + ) + ).rejects.toThrow(fpiError.message); + + await expect( + (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com", name: "n" }, "delete"), + cookieSender + ) + ).rejects.toThrow(fpiError.message); + }); + + it("Firefox 环境下,显式传空字符串 firstPartyDomain 时应保留空字符串(代表 FPI 关闭时创建的 cookie),而非当作未提供", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const getAllSpy = vi.spyOn(cookiesApi, "getAll").mockResolvedValue([]); + const setSpy = vi.spyOn(cookiesApi, "set").mockResolvedValue({} as chrome.cookies.Cookie); + const removeSpy = vi.spyOn(cookiesApi, "remove").mockResolvedValue({} as chrome.cookies.CookieDetails); + + await (GMApi.prototype as any).GM_cookie.call( + makeCookieGMApi(), + makeCookieReq({ url: "https://example.com", firstPartyDomain: "" }, "list"), + cookieSender + ); + expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBe(""); + + await (GMApi.prototype as any).GM_cookie.call( + makeCookieGMApi(), + makeCookieReq({ url: "https://example.com", name: "n", value: "v", firstPartyDomain: " " }, "set"), + cookieSender + ); + expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe(""); + + await (GMApi.prototype as any).GM_cookie.call( + makeCookieGMApi(), + makeCookieReq({ url: "https://example.com", name: "n", firstPartyDomain: "" }, "delete"), + cookieSender + ); + expect(removeSpy.mock.calls[0][0].firstPartyDomain).toBe(""); + }); + + it("Firefox 环境下,set 未提供 firstPartyDomain 时直接省略该字段(无法用 null 表达新 cookie 的归属,不能补默认值)", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + const setSpy = vi.spyOn(cookiesApi, "set").mockResolvedValue({} as chrome.cookies.Cookie); + + await (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com", name: "n", value: "v" }, "set"), + cookieSender + ); + expect(setSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); + }); +}); 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 31eaae2c9..999df0786 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -313,6 +313,8 @@ const supportedRequestMethods = new Set([ export default class GMApi { logger: Logger; + private warnedFirstPartyDomainScriptUuids = new Set(); + scriptDAO: ScriptDAO = new ScriptDAO(); subscribeDAO: SubscribeDAO = new SubscribeDAO(); @@ -458,6 +460,26 @@ export default class GMApi { // string | undefined detail.partitionKey.topLevelSite = undefined; } + + // firstPartyDomain 仅 Firefox 支持;Chrome 会拒绝未知参数,故非 Firefox 下不传递。 + // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分。 + // getAll 未提供时需补字面量 null 才能在 FPI 开启时免于报错,且不按 firstPartyDomain 过滤; + // set/remove 对 null 与未提供一视同仁,补 null 无意义,未提供时交由 Firefox 自身报错。 + // https://github.com/violentmonkey/violentmonkey/issues/746 + const firstPartyDomainRaw = + typeof detail.firstPartyDomain === "string" ? detail.firstPartyDomain.trim() : undefined; + if (firstPartyDomainRaw !== undefined && !this.warnedFirstPartyDomainScriptUuids.has(request.script.uuid)) { + this.warnedFirstPartyDomainScriptUuids.add(request.script.uuid); + this.logger.warn( + isFirefox() + ? "GM_cookie firstPartyDomain is Firefox-specific and may behave differently in other browsers." + : "GM_cookie firstPartyDomain is only supported by Firefox and is ignored in this browser.", + { uuid: request.uuid, name: request.script.name, component: "GM_cookie" } + ); + } + const firstPartyDomainForList: string | null | undefined = isFirefox() ? (firstPartyDomainRaw ?? null) : undefined; + const firstPartyDomainForSetOrDelete: string | undefined = isFirefox() ? firstPartyDomainRaw : undefined; + // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; @@ -482,6 +504,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainForList, }) ); return cookies; @@ -498,6 +521,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainForSetOrDelete, }) ); break; @@ -523,6 +547,7 @@ export default class GMApi { secure: detail.secure, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainForSetOrDelete, }) ); break; diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..571bc84fa 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -472,6 +472,13 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * Firefox-only: the First-Party Isolation key (`privacy.firstparty.isolate`). Not supported on Chrome. + * `list` works without it even when FPI is on (matches cookies across all first-party domains). + * `set`/`delete` require it explicitly when FPI is on; the browser rejects the call otherwise. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { @@ -485,6 +492,11 @@ declare namespace GMTypes { httpOnly: boolean; secure: boolean; sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; + /** + * Firefox-only: the First-Party Isolation key. Empty string if the cookie was set while first-party isolation was off. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } // tabid是只有后台脚本监听才有的参数 diff --git a/src/types/main.d.ts b/src/types/main.d.ts index f0b16b933..b95828f38 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -110,3 +110,22 @@ declare namespace globalThis { Scriptcat?: App.ExternalScriptCat; } } + +// Firefox 在 chrome.* 命名空间下同样支持 browser.cookies 的 firstPartyDomain 参数,但 @types/chrome 未声明 +// @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning +declare namespace chrome.cookies { + interface GetAllDetails { + // getAll 专属:字面量 null 代表不按 firstPartyDomain 过滤,且跳过 FPI 必填校验(remove/set 无此语义,见 + // gm_api.ts 内 GM_cookie 的详细注释,依据实测的 Firefox ext-cookies.js 源码,而非 MDN 文档) + firstPartyDomain?: string | null; + } + interface SetDetails { + firstPartyDomain?: string; + } + interface CookieDetails { + firstPartyDomain?: string; + } + interface Cookie { + firstPartyDomain?: string; + } +} diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..7b6c85a7c 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -537,6 +537,13 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * Firefox-only: the First-Party Isolation key (`privacy.firstparty.isolate`). Not supported on Chrome. + * `list` works without it even when FPI is on (matches cookies across all first-party domains). + * `set`/`delete` require it explicitly when FPI is on; the browser rejects the call otherwise. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { @@ -550,6 +557,11 @@ declare namespace GMTypes { httpOnly: boolean; secure: boolean; sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; + /** + * Firefox-only: the First-Party Isolation key. Empty string if the cookie was set while first-party isolation was off. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } /** Value change listener. `tabid` is only available for background script listeners. */ diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..520444e78 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -543,6 +543,13 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * 仅 Firefox 支持:First-Party Isolation 隔离键(`privacy.firstparty.isolate`)。Chrome 不支持。 + * `list` 即使 FPI 开启也不需要提供(会跨所有 first-party 分区匹配); + * `set`/`delete` 在 FPI 开启时必须显式提供,否则浏览器会拒绝调用。 + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { @@ -556,6 +563,11 @@ declare namespace GMTypes { httpOnly: boolean; secure: boolean; sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; + /** + * 仅 Firefox 支持:First-Party Isolation 隔离键。若该 cookie 是在 First-Party Isolation 关闭时创建的,则为空字符串。 + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } /** 值变化监听器。`tabid` 仅在后台脚本监听器中可用。 */