Skip to content
Draft
22 changes: 22 additions & 0 deletions docs/develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/app/service/content/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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" }),
]);
});
});
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" ||
typeof (details.firstPartyDomain ?? "") !== "string" ||
typeof (details.httpOnly ?? false) !== "boolean" ||
typeof (details.name ?? "") !== "string" ||
typeof (details.partitionKey ?? null) !== "object" ||
Expand Down
190 changes: 189 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 } 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";
Expand Down Expand Up @@ -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<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 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");
});
});
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 @@ -313,6 +313,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 @@ -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;
Expand All @@ -482,6 +504,7 @@ export default class GMApi {
url: detail.url,
storeId: storeId,
partitionKey: stripUndefined(detail.partitionKey),
firstPartyDomain: firstPartyDomainForList,
})
);
return cookies;
Expand All @@ -498,6 +521,7 @@ export default class GMApi {
url: detail.url,
storeId: storeId,
partitionKey: stripUndefined(detail.partitionKey),
firstPartyDomain: firstPartyDomainForSetOrDelete,
})
);
break;
Expand All @@ -523,6 +547,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
19 changes: 19 additions & 0 deletions src/types/main.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading