Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
48248a5
✨ GM_cookie 支持 firstPartyDomain(Firefox First-Party Isolation)
cyfung1031 Jul 11, 2026
70cfe1d
fix
cyfung1031 Jul 11, 2026
1f5a188
🐛 修复 GM_cookie firstPartyDomain 的 action 判断与空字符串语义
cyfung1031 Jul 11, 2026
da77d5b
🔒 修复 GM_cookie firstPartyDomain 未提供时误传 null 导致的跨分区 cookie 泄露
cyfung1031 Jul 11, 2026
f6462fb
🐛 恢复 list/delete 缺省 firstPartyDomain 时补 null,避免 FPI/Tor Browser 下报错回归
cyfung1031 Jul 11, 2026
a9bc4e9
🔒 修正 GM_cookie firstPartyDomain:null 只对 getAll 有意义,set/remove 据实拒绝
cyfung1031 Jul 11, 2026
d97b6ae
update
cyfung1031 Jul 11, 2026
945cdaa
✨ warn on firstPartyDomain usage
cyfung1031 Jul 11, 2026
7295396
📄 document browser-specific compatibility policy
cyfung1031 Jul 11, 2026
5a4f39d
Merge branch 'main' into pr/gm-cookies-firstPartyDomain
cyfung1031 Jul 17, 2026
4477ab3
Merge branch 'main' into pr/gm-cookies-firstPartyDomain
cyfung1031 Jul 20, 2026
634055c
Merge branch 'main' into pr/gm-cookies-firstPartyDomain
cyfung1031 Jul 20, 2026
cf642fe
🔀 同步 PR #1565 到最新 main
cyfung1031 Aug 14, 2026
e7a20b4
🐛 修正 GM_cookie firstPartyDomain 空值校验
cyfung1031 Aug 14, 2026
4d86afa
Merge branch 'main' into pr/gm-cookies-firstPartyDomain
cyfung1031 Sep 8, 2026
f88ba03
🔀 同步 latest main 到 PR #1565 重工分支
cyfung1031 Sep 15, 2026
66b5580
🐛 补全 GM_cookie firstPartyDomain 跨浏览器覆盖
cyfung1031 Sep 15, 2026
7062843
🔀 sync main into PR #1565
cyfung1031 Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ misused as void callbacks. These need type information, so they are *not* part o
- 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.

### Comment Discipline

A comment must tell the reader something the code cannot: an invariant, a race condition, a workaround for a specific constraint, or why something looks wrong but is correct. If deleting it would cost a future reader nothing, delete it.
Expand Down
37 changes: 36 additions & 1 deletion src/app/service/content/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { GMInfoEnv, ScriptFunc } from "../types";
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 type { ScriptRunResource } from "@App/app/repo/scripts";
import { uuidv4 } from "@App/pkg/utils/uuid";
import GMApi from "./gm_api";
const nilFn: ScriptFunc = () => {};

Expand Down Expand Up @@ -1416,3 +1416,38 @@ 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 显式为 null 时应回传 Invalid Argument Type 错误", async () => {
const a = makeA();
const done = vi.fn();
(GMApi as any)._GM_cookie(a, "list", { url: "https://example.com", firstPartyDomain: null }, 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" }),
]);
});
});
1 change: 1 addition & 0 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ export default class GMApi extends GM_Base {
typeof (details || false) !== "object" ||
typeof (details.domain ?? "") !== "string" ||
typeof (details.expirationDate ?? 0) !== "number" ||
(details.firstPartyDomain !== undefined && typeof details.firstPartyDomain !== "string") ||
typeof (details.httpOnly ?? false) !== "boolean" ||
typeof (details.name ?? "") !== "string" ||
typeof (details.partitionKey ?? null) !== "object" ||
Expand Down
209 changes: 208 additions & 1 deletion src/app/service/service_worker/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } 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, {
Expand Down Expand Up @@ -338,3 +338,210 @@ 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<chrome.cookies.Cookie[]>;
set(details: chrome.cookies.SetDetails): Promise<chrome.cookies.Cookie>;
remove(details: chrome.cookies.CookieDetails): Promise<chrome.cookies.CookieDetails>;
};
const makeCookieGMApi = () => ({ logger: { warn: vi.fn() }, warnedFirstPartyDomainScriptUuids: new Set<string>() });

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<string>() };
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<string>() };
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 supported by Firefox and will be passed to the Firefox cookies API; other browsers may behave differently.",
{ 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.set", async () => {
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).toHaveBeenCalledTimes(1);
expect(setSpy.mock.calls[0][0]).not.toHaveProperty("firstPartyDomain");
});

it("非 Firefox 环境下,firstPartyDomain 不会传递给 chrome.cookies.remove", async () => {
const removeSpy = vi.spyOn(cookiesApi, "remove").mockResolvedValue({} as chrome.cookies.CookieDetails);
const req = makeCookieReq({ url: "https://example.com", name: "n", firstPartyDomain: "example.com" }, "delete");
await (GMApi.prototype as any).GM_cookie.call(makeCookieGMApi(), req, cookieSender);
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(removeSpy.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");
});
});
25 changes: 25 additions & 0 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ const supportedRequestMethods = new Set<string>([
export default class GMApi {
logger: Logger;

private warnedFirstPartyDomainScriptUuids = new Set<string>();

scriptDAO: ScriptDAO = new ScriptDAO();
subscribeDAO: SubscribeDAO = new SubscribeDAO();

Expand Down Expand Up @@ -498,6 +500,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 supported by Firefox and will be passed to the Firefox cookies API; other browsers may behave differently."
: "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;
Expand All @@ -522,6 +544,7 @@ export default class GMApi {
url: detail.url,
storeId: storeId,
partitionKey: stripUndefined(detail.partitionKey),
firstPartyDomain: firstPartyDomainForList,
})
);
return cookies;
Expand All @@ -538,6 +561,7 @@ export default class GMApi {
url: detail.url,
storeId: storeId,
partitionKey: stripUndefined(detail.partitionKey),
firstPartyDomain: firstPartyDomainForSetOrDelete,
})
);
break;
Expand All @@ -563,6 +587,7 @@ export default class GMApi {
secure: detail.secure,
storeId: storeId,
partitionKey: stripUndefined(detail.partitionKey),
firstPartyDomain: firstPartyDomainForSetOrDelete,
})
);
break;
Expand Down
12 changes: 12 additions & 0 deletions src/template/scriptcat.d.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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是只有后台脚本监听才有的参数
Expand Down
Loading
Loading