From 48248a5218e086a0c182b1ce1dc63721fe0c1c09 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:46:10 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20GM=5Fcookie=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20firstPartyDomain=EF=BC=88Firefox=20First-Party=20Is?= =?UTF-8?q?olation=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome 不支持该参数(chrome.cookies 会拒绝未知属性),因此仅在 Firefox 下透传给 chrome.cookies.getAll/set/remove,其余浏览器直接忽略该字段。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_api.test.ts | 26 ++++++ src/app/service/content/gm_api/gm_api.ts | 1 + .../service_worker/gm_api/gm_api.test.ts | 62 ++++++++++++- .../service/service_worker/gm_api/gm_api.ts | 88 ++++++++++++------- src/types/main.d.ts | 17 ++++ src/types/scriptcat.d.ts | 5 ++ src/types/scriptcat.zh-CN.d.ts | 5 ++ 7 files changed, 169 insertions(+), 35 deletions(-) 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..3202e9cf7 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,63 @@ 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 是重载函数(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; + }; + + afterEach(() => { + vi.restoreAllMocks(); + // 仅还原本 describe 块自行 stub 的 mozInnerScreenX,避免影响 setup 文件里的全局 chrome stub + delete (globalThis as any).mozInnerScreenX; + }); + + 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({}, 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({}, 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({}, req, cookieSender); + expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); + }); +}); 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..edd82e94a 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -458,6 +458,20 @@ export default class GMApi { // string | undefined detail.partitionKey.topLevelSite = undefined; } + // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 + let firstPartyDomain = undefined; + if (isFirefox()) { + // see https://github.com/violentmonkey/violentmonkey/issues/746 + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation + // When first-party isolation is on, you must provide this option or the API call fails and returns a rejected promise. + // For get(), set(), and remove() you must pass a string value. For getAll(), you may also pass null here, and this gets all cookies, + // whether or not they have a non-empty value for firstPartyDomain. + if (cookieAction === "getAll") { + firstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : null; + } else { + firstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : undefined; + } + } // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; @@ -472,18 +486,20 @@ export default class GMApi { case "list": { detail.domain = detail.domain || undefined; detail.url = detail.url || undefined; - const cookies = await chrome.cookies.getAll( - stripUndefined({ - domain: detail.domain, - name: detail.name, - path: detail.path, - secure: detail.secure, - session: detail.session, - url: detail.url, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }) - ); + const o: Record = stripUndefined({ + domain: detail.domain, + name: detail.name, + path: detail.path, + secure: detail.secure, + session: detail.session, + url: detail.url, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + }); + if (firstPartyDomain !== undefined) { + o.firstPartyDomain = firstPartyDomain; + } + const cookies = await chrome.cookies.getAll(o); return cookies; } case "delete": { @@ -492,14 +508,16 @@ export default class GMApi { if (!detail.url || !detail.name) { throw new Error("delete operation must have url and name"); } - await chrome.cookies.remove( - stripUndefined({ - name: detail.name, - url: detail.url, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }) - ); + const o: any = stripUndefined({ + name: detail.name, + url: detail.url, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + }); + if (firstPartyDomain !== undefined) { + o.firstPartyDomain = firstPartyDomain; + } + await chrome.cookies.remove(o); break; } case "set": { @@ -511,20 +529,22 @@ export default class GMApi { if (!detail.url) { throw new Error("set operation must have url"); } - await chrome.cookies.set( - stripUndefined({ - url: detail.url, - name: detail.name, - domain: detail.domain, - value: detail.value, - expirationDate: detail.expirationDate, - path: detail.path, - httpOnly: detail.httpOnly, - secure: detail.secure, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }) - ); + const o: any = stripUndefined({ + url: detail.url, + name: detail.name, + domain: detail.domain, + value: detail.value, + expirationDate: detail.expirationDate, + path: detail.path, + httpOnly: detail.httpOnly, + secure: detail.secure, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + }); + if (firstPartyDomain !== undefined) { + o.firstPartyDomain = firstPartyDomain; + } + await chrome.cookies.set(o); break; } default: { diff --git a/src/types/main.d.ts b/src/types/main.d.ts index f0b16b933..a83885ac6 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -110,3 +110,20 @@ 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 { + firstPartyDomain?: string; + } + 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..5757c2e49 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -537,6 +537,11 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * Firefox-only: the First-Party Isolation key (`privacy.firstparty.isolate`). Not supported on Chrome. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..cbe68d2c1 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -543,6 +543,11 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * 仅 Firefox 支持:First-Party Isolation 隔离键(`privacy.firstparty.isolate`)。Chrome 不支持。 + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { From 70cfe1db39e490789dcacceef7b12accd4c6b8e9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:24:33 +0900 Subject: [PATCH 2/9] fix Co-Authored-By: Claude Sonnet 5 --- .../service/service_worker/gm_api/gm_api.ts | 99 ++++++++++--------- src/types/main.d.ts | 3 +- 2 files changed, 53 insertions(+), 49 deletions(-) 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 edd82e94a..6c6988dbd 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -458,20 +458,26 @@ export default class GMApi { // string | undefined detail.partitionKey.topLevelSite = undefined; } + // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 - let firstPartyDomain = undefined; + // see https://github.com/violentmonkey/violentmonkey/issues/746 + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation + // When first-party isolation is on, you must provide this option or the API call fails and returns a rejected promise. + // For get(), set(), and remove() you must pass a string value. For getAll(), you may also pass null here, and this gets all cookies, + // whether or not they have a non-empty value for firstPartyDomain. + let firstPartyDomainA: string | null | undefined = undefined; + let firstPartyDomainB: string | undefined = undefined; if (isFirefox()) { - // see https://github.com/violentmonkey/violentmonkey/issues/746 - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation - // When first-party isolation is on, you must provide this option or the API call fails and returns a rejected promise. - // For get(), set(), and remove() you must pass a string value. For getAll(), you may also pass null here, and this gets all cookies, - // whether or not they have a non-empty value for firstPartyDomain. + const trimmedFirstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : ""; if (cookieAction === "getAll") { - firstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : null; + // getAll() 专用:允许显式传 null 代表不按 firstPartyDomain 过滤 + firstPartyDomainA = trimmedFirstPartyDomain || null; } else { - firstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : undefined; + // set()/remove() 专用:不支持 null,未指定时省略该字段 + firstPartyDomainB = trimmedFirstPartyDomain || undefined; } } + // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; @@ -486,20 +492,19 @@ export default class GMApi { case "list": { detail.domain = detail.domain || undefined; detail.url = detail.url || undefined; - const o: Record = stripUndefined({ - domain: detail.domain, - name: detail.name, - path: detail.path, - secure: detail.secure, - session: detail.session, - url: detail.url, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }); - if (firstPartyDomain !== undefined) { - o.firstPartyDomain = firstPartyDomain; - } - const cookies = await chrome.cookies.getAll(o); + const cookies = await chrome.cookies.getAll( + stripUndefined({ + domain: detail.domain, + name: detail.name, + path: detail.path, + secure: detail.secure, + session: detail.session, + url: detail.url, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainA, + }) + ); return cookies; } case "delete": { @@ -508,16 +513,15 @@ export default class GMApi { if (!detail.url || !detail.name) { throw new Error("delete operation must have url and name"); } - const o: any = stripUndefined({ - name: detail.name, - url: detail.url, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }); - if (firstPartyDomain !== undefined) { - o.firstPartyDomain = firstPartyDomain; - } - await chrome.cookies.remove(o); + await chrome.cookies.remove( + stripUndefined({ + name: detail.name, + url: detail.url, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainB, + }) + ); break; } case "set": { @@ -529,22 +533,21 @@ export default class GMApi { if (!detail.url) { throw new Error("set operation must have url"); } - const o: any = stripUndefined({ - url: detail.url, - name: detail.name, - domain: detail.domain, - value: detail.value, - expirationDate: detail.expirationDate, - path: detail.path, - httpOnly: detail.httpOnly, - secure: detail.secure, - storeId: storeId, - partitionKey: stripUndefined(detail.partitionKey), - }); - if (firstPartyDomain !== undefined) { - o.firstPartyDomain = firstPartyDomain; - } - await chrome.cookies.set(o); + await chrome.cookies.set( + stripUndefined({ + url: detail.url, + name: detail.name, + domain: detail.domain, + value: detail.value, + expirationDate: detail.expirationDate, + path: detail.path, + httpOnly: detail.httpOnly, + secure: detail.secure, + storeId: storeId, + partitionKey: stripUndefined(detail.partitionKey), + firstPartyDomain: firstPartyDomainB, + }) + ); break; } default: { diff --git a/src/types/main.d.ts b/src/types/main.d.ts index a83885ac6..bb3bbfc42 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -115,7 +115,8 @@ declare namespace globalThis { // @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning declare namespace chrome.cookies { interface GetAllDetails { - firstPartyDomain?: string; + // getAll() 额外接受 null,代表不按 firstPartyDomain 过滤(见上方 MDN 链接) + firstPartyDomain?: string | null; } interface SetDetails { firstPartyDomain?: string; From 1f5a1884e085324c8be6338b2539b3ef7ebddbd2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:13:25 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5Fco?= =?UTF-8?q?okie=20firstPartyDomain=20=E7=9A=84=20action=20=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E4=B8=8E=E7=A9=BA=E5=AD=97=E7=AC=A6=E4=B8=B2=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list 分支误判 cookieAction === "getAll"(实际值为 "list"),导致 Firefox 下 firstPartyDomain 从未真正传给 chrome.cookies.getAll;改为区分「是否提供」而非真值判断,未提供时按 MDN 语义传 null(getAll)或省略(set/remove),提供空字符串时如实保留(代表 FPI 关闭时创建的 cookie)。 - 补齐 GMTypes.Cookie.firstPartyDomain(英/中文类型声明)及内置编辑器默认类型定义 src/template/scriptcat.d.tpl 的 CookieDetails/Cookie,避免与独立声明文件不同步。 - 新增空字符串/纯空白、list 未提供时传 null、set/delete 未提供时省略字段的单元测试。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 59 ++++++++++++++++++- .../service/service_worker/gm_api/gm_api.ts | 34 +++++------ src/template/scriptcat.d.tpl | 10 ++++ src/types/scriptcat.d.ts | 5 ++ src/types/scriptcat.zh-CN.d.ts | 5 ++ 5 files changed, 94 insertions(+), 19 deletions(-) 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 3202e9cf7..5e5389451 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 @@ -230,11 +230,12 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation }; (cookieSender as any).getExtMessageSender = () => ({ tabId: -1 }) as ExtMessageSender; - // chrome.cookies.getAll/set 是重载函数(Promise 或 callback 两种签名),vi.spyOn 只能推断出最后一个重载(callback/void); + // 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; }; afterEach(() => { @@ -269,4 +270,60 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation await (GMApi.prototype as any).GM_cookie.call({}, req, cookieSender); expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); + + it("Firefox 环境下,list 未提供 firstPartyDomain 时传 null(不按 firstPartyDomain 过滤,而非省略该字段)", 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 环境下,显式传空字符串 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( + {}, + makeCookieReq({ url: "https://example.com", firstPartyDomain: "" }, "list"), + cookieSender + ); + expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBe(""); + + await (GMApi.prototype as any).GM_cookie.call( + {}, + 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( + {}, + makeCookieReq({ url: "https://example.com", name: "n", firstPartyDomain: "" }, "delete"), + cookieSender + ); + expect(removeSpy.mock.calls[0][0].firstPartyDomain).toBe(""); + }); + + it("Firefox 环境下,set/delete 未提供 firstPartyDomain 时直接省略该字段(不支持传 null)", async () => { + vi.stubGlobal("mozInnerScreenX", 0); + 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( + {}, + makeCookieReq({ url: "https://example.com", name: "n", value: "v" }, "set"), + cookieSender + ); + expect(setSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); + + await (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com", name: "n" }, "delete"), + cookieSender + ); + expect(removeSpy.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 6c6988dbd..ea8a6a90d 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -462,21 +462,19 @@ export default class GMApi { // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 // see https://github.com/violentmonkey/violentmonkey/issues/746 // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation - // When first-party isolation is on, you must provide this option or the API call fails and returns a rejected promise. - // For get(), set(), and remove() you must pass a string value. For getAll(), you may also pass null here, and this gets all cookies, - // whether or not they have a non-empty value for firstPartyDomain. - let firstPartyDomainA: string | null | undefined = undefined; - let firstPartyDomainB: string | undefined = undefined; - if (isFirefox()) { - const trimmedFirstPartyDomain = detail.firstPartyDomain ? `${detail.firstPartyDomain}`.trim() : ""; - if (cookieAction === "getAll") { - // getAll() 专用:允许显式传 null 代表不按 firstPartyDomain 过滤 - firstPartyDomainA = trimmedFirstPartyDomain || null; - } else { - // set()/remove() 专用:不支持 null,未指定时省略该字段 - firstPartyDomainB = trimmedFirstPartyDomain || undefined; - } - } + // FPI 开启时该参数为必填,否则调用会被拒绝;"" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined/null。 + // getAll() 额外支持传 null:代表不按 firstPartyDomain 过滤,涵盖所有值(含未设置的 cookie);set()/remove() 只接受 string,不支持 null。 + const firstPartyDomainSupplied = typeof detail.firstPartyDomain === "string"; + const firstPartyDomainTrimmed = firstPartyDomainSupplied ? detail.firstPartyDomain!.trim() : undefined; + // list(对应 chrome.cookies.getAll):未提供时传 null,不按 firstPartyDomain 过滤 + const firstPartyDomainForList: string | null | undefined = isFirefox() + ? firstPartyDomainSupplied + ? firstPartyDomainTrimmed + : null + : undefined; + // set / delete(对应 chrome.cookies.set / remove):未提供时直接省略该字段 + const firstPartyDomainForMutation: string | undefined = + isFirefox() && firstPartyDomainSupplied ? firstPartyDomainTrimmed : undefined; // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; @@ -502,7 +500,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainA, + firstPartyDomain: firstPartyDomainForList, }) ); return cookies; @@ -519,7 +517,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainB, + firstPartyDomain: firstPartyDomainForMutation, }) ); break; @@ -545,7 +543,7 @@ export default class GMApi { secure: detail.secure, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainB, + firstPartyDomain: firstPartyDomainForMutation, }) ); break; diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..7322615ca 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -472,6 +472,11 @@ declare namespace GMTypes { httpOnly?: boolean; expirationDate?: number; partitionKey?: CookieDetailsPartitionKeyType; + /** + * Firefox-only: the First-Party Isolation key (`privacy.firstparty.isolate`). Not supported on Chrome. + * @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + */ + firstPartyDomain?: string; } interface Cookie { @@ -485,6 +490,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/scriptcat.d.ts b/src/types/scriptcat.d.ts index 5757c2e49..062e0c501 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -555,6 +555,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 cbe68d2c1..65efcac11 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -561,6 +561,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` 仅在后台脚本监听器中可用。 */ From da77d5bf4d145978b50d27fb9e0ee83fbf7523fb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:23:54 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=94=92=20=E4=BF=AE=E5=A4=8D=20GM=5Fco?= =?UTF-8?q?okie=20firstPartyDomain=20=E6=9C=AA=E6=8F=90=E4=BE=9B=E6=97=B6?= =?UTF-8?q?=E8=AF=AF=E4=BC=A0=20null=20=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E8=B7=A8=E5=88=86=E5=8C=BA=20cookie=20=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chrome.cookies.getAll({firstPartyDomain: null}) 会跨越所有 first-party 分区查询,等同绕过 Firefox 的 First-Party Isolation。 GM_cookie 的权限校验只验证目标 hostname,未考虑调用脚本所在的 first-party 上下文,之前默认给未提供的 firstPartyDomain 补 null, 会让脚本读取到与自己运行上下文无关的第三方分区下的同名 cookie(见 violentmonkey/violentmonkey#1467)。 现在未提供该字段时一律省略(不再区分 list 与 set/delete),只有脚本显式提供时才透传给 chrome.cookies.getAll/set/remove; 相应移除 chrome.cookies.GetAllDetails.firstPartyDomain 的 null 联合类型标注与失效测试。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 4 +-- .../service/service_worker/gm_api/gm_api.ts | 26 +++++++------------ src/types/main.d.ts | 3 +-- 3 files changed, 13 insertions(+), 20 deletions(-) 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 5e5389451..a752134f6 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 @@ -271,12 +271,12 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); - it("Firefox 环境下,list 未提供 firstPartyDomain 时传 null(不按 firstPartyDomain 过滤,而非省略该字段)", async () => { + it("Firefox 环境下,list 未提供 firstPartyDomain 时应直接省略该字段,不能补 null(null 会跨 first-party 分区查询,绕过 FPI)", 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(); + expect(getAllSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); }); it("Firefox 环境下,显式传空字符串 firstPartyDomain 时应保留空字符串(代表 FPI 关闭时创建的 cookie),而非当作未提供", async () => { 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 ea8a6a90d..b0210cc14 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -462,19 +462,13 @@ export default class GMApi { // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 // see https://github.com/violentmonkey/violentmonkey/issues/746 // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation - // FPI 开启时该参数为必填,否则调用会被拒绝;"" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined/null。 - // getAll() 额外支持传 null:代表不按 firstPartyDomain 过滤,涵盖所有值(含未设置的 cookie);set()/remove() 只接受 string,不支持 null。 - const firstPartyDomainSupplied = typeof detail.firstPartyDomain === "string"; - const firstPartyDomainTrimmed = firstPartyDomainSupplied ? detail.firstPartyDomain!.trim() : undefined; - // list(对应 chrome.cookies.getAll):未提供时传 null,不按 firstPartyDomain 过滤 - const firstPartyDomainForList: string | null | undefined = isFirefox() - ? firstPartyDomainSupplied - ? firstPartyDomainTrimmed - : null - : undefined; - // set / delete(对应 chrome.cookies.set / remove):未提供时直接省略该字段 - const firstPartyDomainForMutation: string | undefined = - isFirefox() && firstPartyDomainSupplied ? firstPartyDomainTrimmed : undefined; + // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined。 + // 未提供时一律省略该字段,绝不主动补 null:chrome.cookies.getAll({firstPartyDomain:null}) 会跨越所有 + // first-party 分区查询,而 GM_cookie 的权限校验只验证目标 hostname、未考虑调用脚本所在的 first-party 上下文, + // 若默认传 null 等同让脚本绕过 FPI 读取任意第三方上下文下的同名 cookie,构成信息泄露 + // (见 https://github.com/violentmonkey/violentmonkey/issues/1467)。 + const firstPartyDomain = + isFirefox() && typeof detail.firstPartyDomain === "string" ? detail.firstPartyDomain.trim() : undefined; // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; @@ -500,7 +494,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForList, + firstPartyDomain, }) ); return cookies; @@ -517,7 +511,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForMutation, + firstPartyDomain, }) ); break; @@ -543,7 +537,7 @@ export default class GMApi { secure: detail.secure, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForMutation, + firstPartyDomain, }) ); break; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index bb3bbfc42..a83885ac6 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -115,8 +115,7 @@ declare namespace globalThis { // @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning declare namespace chrome.cookies { interface GetAllDetails { - // getAll() 额外接受 null,代表不按 firstPartyDomain 过滤(见上方 MDN 链接) - firstPartyDomain?: string | null; + firstPartyDomain?: string; } interface SetDetails { firstPartyDomain?: string; From f6462fbd591d4a864f3381cac8d3a944dd8e0d92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:36:41 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=90=9B=20=E6=81=A2=E5=A4=8D=20list/de?= =?UTF-8?q?lete=20=E7=BC=BA=E7=9C=81=20firstPartyDomain=20=E6=97=B6?= =?UTF-8?q?=E8=A1=A5=20null=EF=BC=8C=E9=81=BF=E5=85=8D=20FPI/Tor=20Browser?= =?UTF-8?q?=20=E4=B8=8B=E6=8A=A5=E9=94=99=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版为规避 null 跨分区查询的隐患,把未提供时一律省略该字段,但这重新引入了 violentmonkey/violentmonkey#746 描述的问题:FPI(含 Tor Browser 的默认设置)开启时该参数为必填,缺省会导致 chrome.cookies.getAll/remove 的 promise 被拒绝,即 GM_cookie 的 list/delete 在这些环境下报错。 对齐 Violentmonkey 的实际实现(其 checkCookieOpts 对 CookieList/CookieDelete 均补 null,仅 CookieSet 不补): list/delete 未提供时补 null,set 未提供时仍省略(无法用 null 表达新 cookie 的归属)。 "按调用标签页的 first-party 上下文收紧 firstPartyDomain"(而非放宽为 null)目前没有可靠算法可从 WebExtension API 计算(需感知 privacy.firstparty.isolate.use_site 等无法读取的偏好),Violentmonkey 自身对此有长期开放但 未实现的讨论(violentmonkey/violentmonkey#1467),本次不在此 PR 范围内引入。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 30 +++++++++------- .../service/service_worker/gm_api/gm_api.ts | 36 ++++++++++++------- src/types/main.d.ts | 5 +-- 3 files changed, 44 insertions(+), 27 deletions(-) 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 a752134f6..60e5ffee6 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 @@ -271,12 +271,24 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); - it("Firefox 环境下,list 未提供 firstPartyDomain 时应直接省略该字段,不能补 null(null 会跨 first-party 分区查询,绕过 FPI)", async () => { + it("Firefox 环境下,list/delete 未提供 firstPartyDomain 时补 null(与 Violentmonkey 一致,避免 FPI/Tor Browser 下报错,见 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]).not.toHaveProperty("firstPartyDomain"); + const removeSpy = vi.spyOn(cookiesApi, "remove").mockResolvedValue({} as chrome.cookies.CookieDetails); + + await (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com" }, "list"), + cookieSender + ); + expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBeNull(); + + await (GMApi.prototype as any).GM_cookie.call( + {}, + makeCookieReq({ url: "https://example.com", name: "n" }, "delete"), + cookieSender + ); + expect(removeSpy.mock.calls[0][0].firstPartyDomain).toBeNull(); }); it("Firefox 环境下,显式传空字符串 firstPartyDomain 时应保留空字符串(代表 FPI 关闭时创建的 cookie),而非当作未提供", async () => { @@ -307,10 +319,9 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation expect(removeSpy.mock.calls[0][0].firstPartyDomain).toBe(""); }); - it("Firefox 环境下,set/delete 未提供 firstPartyDomain 时直接省略该字段(不支持传 null)", async () => { + it("Firefox 环境下,set 未提供 firstPartyDomain 时直接省略该字段(无法用 null 表达新 cookie 的归属,不能补默认值)", async () => { vi.stubGlobal("mozInnerScreenX", 0); 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( {}, @@ -318,12 +329,5 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation cookieSender ); expect(setSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); - - await (GMApi.prototype as any).GM_cookie.call( - {}, - makeCookieReq({ url: "https://example.com", name: "n" }, "delete"), - cookieSender - ); - expect(removeSpy.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 b0210cc14..60de8e9b5 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -460,15 +460,27 @@ export default class GMApi { } // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 - // see https://github.com/violentmonkey/violentmonkey/issues/746 - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#first-party_isolation - // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined。 - // 未提供时一律省略该字段,绝不主动补 null:chrome.cookies.getAll({firstPartyDomain:null}) 会跨越所有 - // first-party 分区查询,而 GM_cookie 的权限校验只验证目标 hostname、未考虑调用脚本所在的 first-party 上下文, - // 若默认传 null 等同让脚本绕过 FPI 读取任意第三方上下文下的同名 cookie,构成信息泄露 - // (见 https://github.com/violentmonkey/violentmonkey/issues/1467)。 - const firstPartyDomain = - isFirefox() && typeof detail.firstPartyDomain === "string" ? detail.firstPartyDomain.trim() : undefined; + // 做法与 Violentmonkey 一致(见其 checkCookieOpts 实现): + // - list/delete 未提供时补 null:FPI 开启(含 Tor Browser)时该参数为必填,缺省会导致 promise 被拒绝, + // 见 https://github.com/violentmonkey/violentmonkey/issues/746;getAll/remove 支持 null 代表不按 + // firstPartyDomain 过滤。 + // - set 不做默认值:没有能代表"任意归属"的值可写入新 cookie,未提供时直接省略该字段。 + // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined/null。 + // + // 已知取舍:null 会令查询跨越所有 first-party 分区,而 GM_cookie 的权限校验只验证目标 hostname、未限定 + // 调用脚本自身的 first-party 上下文;将其正确收紧到"调用标签页当前分区"需要按 Firefox 内部算法计算 + // eTLD+1(并感知 privacy.firstparty.isolate.use_site 等无法从 WebExtension API 读取的偏好),这在生态中 + // 仍是未解决的问题——Violentmonkey 自身对此有长期开放的讨论且未实现收紧,见 + // https://github.com/violentmonkey/violentmonkey/issues/1467。 + const firstPartyDomainSupplied = typeof detail.firstPartyDomain === "string"; + const firstPartyDomainTrimmed = firstPartyDomainSupplied ? detail.firstPartyDomain!.trim() : undefined; + const firstPartyDomainForListOrDelete: string | null | undefined = isFirefox() + ? firstPartyDomainSupplied + ? firstPartyDomainTrimmed + : null + : undefined; + const firstPartyDomainForSet: string | undefined = + isFirefox() && firstPartyDomainSupplied ? firstPartyDomainTrimmed : undefined; // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; @@ -494,7 +506,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain, + firstPartyDomain: firstPartyDomainForListOrDelete, }) ); return cookies; @@ -511,7 +523,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain, + firstPartyDomain: firstPartyDomainForListOrDelete, }) ); break; @@ -537,7 +549,7 @@ export default class GMApi { secure: detail.secure, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain, + firstPartyDomain: firstPartyDomainForSet, }) ); break; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index a83885ac6..eb3fb869e 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -115,13 +115,14 @@ declare namespace globalThis { // @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning declare namespace chrome.cookies { interface GetAllDetails { - firstPartyDomain?: string; + // null 代表不按 firstPartyDomain 过滤(getAll 专用,remove 通过 CookieDetails 复用同一约定) + firstPartyDomain?: string | null; } interface SetDetails { firstPartyDomain?: string; } interface CookieDetails { - firstPartyDomain?: string; + firstPartyDomain?: string | null; } interface Cookie { firstPartyDomain?: string; From a9bc4e9f86e77dbef443a8edf5d93c92a5653fbb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:58:50 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=94=92=20=E4=BF=AE=E6=AD=A3=20GM=5Fco?= =?UTF-8?q?okie=20firstPartyDomain=EF=BC=9Anull=20=E5=8F=AA=E5=AF=B9=20get?= =?UTF-8?q?All=20=E6=9C=89=E6=84=8F=E4=B9=89=EF=BC=8Cset/remove=20?= =?UTF-8?q?=E6=8D=AE=E5=AE=9E=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 list 与 delete 一并按 Violentmonkey 的写法补 null,但直接查阅 Firefox 源码 (toolkit/components/extensions/parent/ext-cookies.js,而非 MDN 文档)后确认两者处理并不对称: - getAll():`if (!("firstPartyDomain" in details)) validateFirstPartyDomain(details)` —— 只有「完全没有这个 key」才会在 FPI 开启时报错;key 存在但值为 null/undefined 会跳过校验, 且 oaFromDetails 在 allowPattern=true(仅 getAll)时会因此丢弃 firstPartyDomain 过滤条件。 因此 list 必须显式传字面量 null(而非省略整个字段,stripUndefined 只会剔除 undefined)。 - set() / remove():`validateFirstPartyDomain(details)` 无条件执行,且该函数用 `!= null` 判断,对 null 与 undefined 一视同仁;oaFromDetails 在 allowPattern=false(set/remove)时 用 `details.firstPartyDomain ?? ""`,null 与未提供得到完全相同的结果。也就是说给 set/remove 补 null 是无效动作:既不能避免 FPI 开启时的报错,也没有「跨分区」的含义。 因此改为: - list 未提供时补字面量 null(对 getAll 才有效,避免 violentmonkey/violentmonkey#746 描述的 FPI/Tor Browser 报错)。 - set/delete 未提供时直接省略该字段;FPI 开启且脚本未提供时,让 Firefox 自身抛出 "... required 'firstPartyDomain' attribute was not set." 并原样传播给调用方,不去伪造 一个值掩盖这个限制。 「list 的 null 会跨越所有 first-party 分区、GM_cookie 权限校验只验证 hostname 未限定调用脚本 自身分区」这一取舍依旧存在且注释里已说明:正确收紧到调用标签页当前分区需要复刻 Firefox 内部 eTLD+1 算法并感知无法从 WebExtension API 读取的偏好,Violentmonkey 自身对此有长期开放但未 实现的讨论(violentmonkey/violentmonkey#1467),不在本 PR 范围内引入。 同步修正 chrome.cookies.CookieDetails(remove 使用)类型标注,去掉不再需要的 null 联合; 补充 GMTypes.CookieDetails.firstPartyDomain 的文档说明 set/delete 在 FPI 下必须显式提供; 测试更新为验证 list 补 null、delete 省略字段、以及 set/delete 在 FPI 拒绝时原样传播错误。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 47 ++++++++++++++----- .../service/service_worker/gm_api/gm_api.ts | 37 +++++++++------ src/template/scriptcat.d.tpl | 2 + src/types/main.d.ts | 5 +- src/types/scriptcat.d.ts | 2 + src/types/scriptcat.zh-CN.d.ts | 2 + 6 files changed, 66 insertions(+), 29 deletions(-) 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 60e5ffee6..d3e80b5aa 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 @@ -271,24 +271,45 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); - it("Firefox 环境下,list/delete 未提供 firstPartyDomain 时补 null(与 Violentmonkey 一致,避免 FPI/Tor Browser 下报错,见 violentmonkey#746)", async () => { + 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 removeSpy = vi.spyOn(cookiesApi, "remove").mockResolvedValue({} as chrome.cookies.CookieDetails); - - await (GMApi.prototype as any).GM_cookie.call( - {}, - makeCookieReq({ url: "https://example.com" }, "list"), - cookieSender - ); + 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(); + }); - await (GMApi.prototype as any).GM_cookie.call( - {}, - makeCookieReq({ url: "https://example.com", name: "n" }, "delete"), - cookieSender + 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." ); - expect(removeSpy.mock.calls[0][0].firstPartyDomain).toBeNull(); + 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 () => { 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 60de8e9b5..5d844a1cf 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -460,26 +460,35 @@ export default class GMApi { } // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 - // 做法与 Violentmonkey 一致(见其 checkCookieOpts 实现): - // - list/delete 未提供时补 null:FPI 开启(含 Tor Browser)时该参数为必填,缺省会导致 promise 被拒绝, - // 见 https://github.com/violentmonkey/violentmonkey/issues/746;getAll/remove 支持 null 代表不按 - // firstPartyDomain 过滤。 - // - set 不做默认值:没有能代表"任意归属"的值可写入新 cookie,未提供时直接省略该字段。 // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined/null。 // - // 已知取舍:null 会令查询跨越所有 first-party 分区,而 GM_cookie 的权限校验只验证目标 hostname、未限定 - // 调用脚本自身的 first-party 上下文;将其正确收紧到"调用标签页当前分区"需要按 Firefox 内部算法计算 - // eTLD+1(并感知 privacy.firstparty.isolate.use_site 等无法从 WebExtension API 读取的偏好),这在生态中 - // 仍是未解决的问题——Violentmonkey 自身对此有长期开放的讨论且未实现收紧,见 + // list(getAll)与 set/delete(set/remove)在 Firefox 源码里的处理并不对称,行为依据实测的 + // toolkit/components/extensions/parent/ext-cookies.js(非 MDN 文档,MDN 未写清楚这个区别): + // - getAll 显式区分"完全没有这个 key"与"key 存在但值为 null/undefined":只有前者才会在 FPI 开启时报错 + // (见 getAll() 里 `if (!("firstPartyDomain" in details)) validateFirstPartyDomain(details)`),后者会 + // 直接跳过校验、且不按 firstPartyDomain 过滤(等同 Violentmonkey 生产环境的 `firstPartyDomain: null` + // 补偿写法,见 https://github.com/violentmonkey/violentmonkey/issues/746)。因此 list 必须显式传字面量 + // null(而非直接省略整个字段),stripUndefined 只会剔除 undefined、不会剔除 null,符合需要。 + // - set/remove 无论传 null 还是完全不传,validateFirstPartyDomain 都无条件执行且视二者等价 + // (`details.firstPartyDomain != null` 对 null/undefined 皆为 false);因此对 set/remove 补 null 毫无 + // 意义——FPI 开启时未提供仍会被 Firefox 拒绝(ExtensionError: "... required 'firstPartyDomain' attribute + // was not set."),只能让脚本自行提供,不去伪造一个值掩盖这个限制。 + // + // 已知取舍(list 专属):null 会令查询跨越所有 first-party 分区,而 GM_cookie 的权限校验只验证目标 + // hostname、未限定调用脚本自身的 first-party 上下文;将其正确收紧到"调用标签页当前分区"需要按 Firefox + // 内部算法计算 eTLD+1(并感知 privacy.firstparty.isolate.use_site 等无法从 WebExtension API 读取的偏好), + // 这在生态中仍是未解决的问题——Violentmonkey 自身对此有长期开放的讨论且未实现收紧,见 // https://github.com/violentmonkey/violentmonkey/issues/1467。 const firstPartyDomainSupplied = typeof detail.firstPartyDomain === "string"; const firstPartyDomainTrimmed = firstPartyDomainSupplied ? detail.firstPartyDomain!.trim() : undefined; - const firstPartyDomainForListOrDelete: string | null | undefined = isFirefox() + // list 专属:未提供时必须补字面量 null,否则 FPI 开启时 chrome.cookies.getAll 会报错(见上方注释) + const firstPartyDomainForList: string | null | undefined = isFirefox() ? firstPartyDomainSupplied ? firstPartyDomainTrimmed : null : undefined; - const firstPartyDomainForSet: string | undefined = + // set/delete 专属:未提供时直接省略该字段;FPI 开启且脚本未提供时,交由 Firefox 自身报错 + const firstPartyDomainForSetOrDelete: string | undefined = isFirefox() && firstPartyDomainSupplied ? firstPartyDomainTrimmed : undefined; // 处理tab的storeid @@ -506,7 +515,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForListOrDelete, + firstPartyDomain: firstPartyDomainForList, }) ); return cookies; @@ -523,7 +532,7 @@ export default class GMApi { url: detail.url, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForListOrDelete, + firstPartyDomain: firstPartyDomainForSetOrDelete, }) ); break; @@ -549,7 +558,7 @@ export default class GMApi { secure: detail.secure, storeId: storeId, partitionKey: stripUndefined(detail.partitionKey), - firstPartyDomain: firstPartyDomainForSet, + firstPartyDomain: firstPartyDomainForSetOrDelete, }) ); break; diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index 7322615ca..571bc84fa 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -474,6 +474,8 @@ declare namespace GMTypes { 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; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index eb3fb869e..b95828f38 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -115,14 +115,15 @@ declare namespace globalThis { // @link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning declare namespace chrome.cookies { interface GetAllDetails { - // null 代表不按 firstPartyDomain 过滤(getAll 专用,remove 通过 CookieDetails 复用同一约定) + // 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 | null; + firstPartyDomain?: string; } interface Cookie { firstPartyDomain?: string; diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 062e0c501..7b6c85a7c 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -539,6 +539,8 @@ declare namespace GMTypes { 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; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 65efcac11..520444e78 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -545,6 +545,8 @@ declare namespace GMTypes { 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; From d97b6aec1fcd76a6c049c6be2f0147d59b695879 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:00:22 +0900 Subject: [PATCH 7/9] update --- .../service/service_worker/gm_api/gm_api.ts | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) 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 5d844a1cf..c993d5b78 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -459,37 +459,15 @@ export default class GMApi { detail.partitionKey.topLevelSite = undefined; } - // firstPartyDomain 仅 Firefox 支持(First-Party Isolation);Chrome 的 chrome.cookies 参数校验会拒绝未知属性,故不传递 - // "" 是合法值(代表该 cookie 是在 FPI 关闭时创建的),须与"未提供"区分,不能一并转成 undefined/null。 - // - // list(getAll)与 set/delete(set/remove)在 Firefox 源码里的处理并不对称,行为依据实测的 - // toolkit/components/extensions/parent/ext-cookies.js(非 MDN 文档,MDN 未写清楚这个区别): - // - getAll 显式区分"完全没有这个 key"与"key 存在但值为 null/undefined":只有前者才会在 FPI 开启时报错 - // (见 getAll() 里 `if (!("firstPartyDomain" in details)) validateFirstPartyDomain(details)`),后者会 - // 直接跳过校验、且不按 firstPartyDomain 过滤(等同 Violentmonkey 生产环境的 `firstPartyDomain: null` - // 补偿写法,见 https://github.com/violentmonkey/violentmonkey/issues/746)。因此 list 必须显式传字面量 - // null(而非直接省略整个字段),stripUndefined 只会剔除 undefined、不会剔除 null,符合需要。 - // - set/remove 无论传 null 还是完全不传,validateFirstPartyDomain 都无条件执行且视二者等价 - // (`details.firstPartyDomain != null` 对 null/undefined 皆为 false);因此对 set/remove 补 null 毫无 - // 意义——FPI 开启时未提供仍会被 Firefox 拒绝(ExtensionError: "... required 'firstPartyDomain' attribute - // was not set."),只能让脚本自行提供,不去伪造一个值掩盖这个限制。 - // - // 已知取舍(list 专属):null 会令查询跨越所有 first-party 分区,而 GM_cookie 的权限校验只验证目标 - // hostname、未限定调用脚本自身的 first-party 上下文;将其正确收紧到"调用标签页当前分区"需要按 Firefox - // 内部算法计算 eTLD+1(并感知 privacy.firstparty.isolate.use_site 等无法从 WebExtension API 读取的偏好), - // 这在生态中仍是未解决的问题——Violentmonkey 自身对此有长期开放的讨论且未实现收紧,见 - // https://github.com/violentmonkey/violentmonkey/issues/1467。 - const firstPartyDomainSupplied = typeof detail.firstPartyDomain === "string"; - const firstPartyDomainTrimmed = firstPartyDomainSupplied ? detail.firstPartyDomain!.trim() : undefined; - // list 专属:未提供时必须补字面量 null,否则 FPI 开启时 chrome.cookies.getAll 会报错(见上方注释) - const firstPartyDomainForList: string | null | undefined = isFirefox() - ? firstPartyDomainSupplied - ? firstPartyDomainTrimmed - : null - : undefined; - // set/delete 专属:未提供时直接省略该字段;FPI 开启且脚本未提供时,交由 Firefox 自身报错 - const firstPartyDomainForSetOrDelete: string | undefined = - isFirefox() && firstPartyDomainSupplied ? firstPartyDomainTrimmed : 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; + const firstPartyDomainForList: string | null | undefined = isFirefox() ? (firstPartyDomainRaw ?? null) : undefined; + const firstPartyDomainForSetOrDelete: string | undefined = isFirefox() ? firstPartyDomainRaw : undefined; // 处理tab的storeid const tabId = sender.getExtMessageSender().tabId; From 945cdaa976c63ed14461f52c763d83b260716530 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:08:54 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=A8=20warn=20on=20firstPartyDomain=20?= =?UTF-8?q?usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_api.test.ts | 58 +++++++++++++++++-- .../service/service_worker/gm_api/gm_api.ts | 11 ++++ 2 files changed, 63 insertions(+), 6 deletions(-) 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 d3e80b5aa..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 @@ -237,6 +237,7 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation set(details: chrome.cookies.SetDetails): Promise; remove(details: chrome.cookies.CookieDetails): Promise; }; + const makeCookieGMApi = () => ({ logger: { warn: vi.fn() }, warnedFirstPartyDomainScriptUuids: new Set() }); afterEach(() => { vi.restoreAllMocks(); @@ -244,10 +245,55 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation 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({}, req, cookieSender); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); expect(getAllSpy).toHaveBeenCalledTimes(1); expect(getAllSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain"); }); @@ -256,7 +302,7 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation 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({}, req, cookieSender); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); expect(getAllSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); @@ -267,7 +313,7 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation { url: "https://example.com", name: "n", value: "v", firstPartyDomain: "example.com" }, "set" ); - await (GMApi.prototype as any).GM_cookie.call({}, req, cookieSender); + await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender); expect(setSpy.mock.calls[0][0].firstPartyDomain).toBe("example.com"); }); @@ -319,21 +365,21 @@ describe("GM_cookie 的 firstPartyDomain 参数(Firefox First-Party Isolation 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 ); 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 c993d5b78..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(); @@ -466,6 +468,15 @@ export default class GMApi { // 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; From 72953960e112e00e5165fd42ae432c18fd399c00 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:18:55 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=93=84=20document=20browser-specific?= =?UTF-8?q?=20compatibility=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/develop.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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