Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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, 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, { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api";
Expand Down Expand Up @@ -231,3 +231,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 @@ -113,3 +113,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