diff --git a/packages/chrome-extension-mock/declarativ_net_request.ts b/packages/chrome-extension-mock/declarativ_net_request.ts
index af50dae54..694092e8d 100644
--- a/packages/chrome-extension-mock/declarativ_net_request.ts
+++ b/packages/chrome-extension-mock/declarativ_net_request.ts
@@ -1,7 +1,12 @@
export default class DeclarativeNetRequest {
MAX_NUMBER_OF_SESSION_RULES = 5000;
+ MAX_NUMBER_OF_DYNAMIC_RULES = 30000;
+ MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES = 5000;
+ MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000;
private _sessionRules: chrome.declarativeNetRequest.Rule[] = [];
+ private _dynamicRules: chrome.declarativeNetRequest.Rule[] = [];
+ dynamicUpdateError: string | undefined;
HeaderOperation = {
APPEND: "append",
@@ -74,4 +79,38 @@ export default class DeclarativeNetRequest {
getSessionRules(): Promise {
return Promise.resolve([...this._sessionRules]);
}
+
+ updateDynamicRules(options: chrome.declarativeNetRequest.UpdateRuleOptions, callback?: () => void): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.dynamicUpdateError) {
+ const message = this.dynamicUpdateError;
+ this.dynamicUpdateError = undefined;
+ (chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError = { message };
+ callback?.();
+ delete (chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError;
+ reject(new Error(message));
+ return;
+ }
+ const removeRuleIds = options.removeRuleIds ?? [];
+ const rules = this._dynamicRules.filter((rule) => !removeRuleIds.includes(rule.id));
+ for (const newRule of options.addRules ?? []) {
+ const index = rules.findIndex((rule) => rule.id === newRule.id);
+ if (index === -1) rules.push(newRule);
+ else rules[index] = newRule;
+ }
+ this._dynamicRules = rules;
+ callback?.();
+ resolve();
+ });
+ }
+
+ getDynamicRules(): Promise {
+ return Promise.resolve([...this._dynamicRules]);
+ }
+
+ resetMock() {
+ this._sessionRules = [];
+ this._dynamicRules = [];
+ this.dynamicUpdateError = undefined;
+ }
}
diff --git a/packages/chrome-extension-mock/storage.ts b/packages/chrome-extension-mock/storage.ts
index b0a821b32..611e4d9b4 100644
--- a/packages/chrome-extension-mock/storage.ts
+++ b/packages/chrome-extension-mock/storage.ts
@@ -7,6 +7,30 @@ type Schema = Record;
type Callback = (arg: T) => void;
+function isPlainObject(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+/**
+ * 真实 chrome.storage.* 写入时会序列化,读回的对象与调用方不再共享引用,且对象键按字母序重排
+ * (已在真实 Chrome 中实测)。mock 若原样保存调用方的对象,就会让依赖键序或引用相等的比较在单测里
+ * 恒真、在浏览器里失败,属于整类盲区,因此这里复现序列化语义。
+ * 只递归普通对象与数组:Date、Map 等非普通对象的真实序列化损耗未实测,不在此处臆造。
+ */
+function serializeLikeChromeStorage(value: T): T {
+ if (Array.isArray(value)) return value.map(serializeLikeChromeStorage) as unknown as T;
+ if (isPlainObject(value)) {
+ const out: Record = {};
+ for (const key of Object.keys(value).sort()) {
+ out[key] = serializeLikeChromeStorage(value[key]);
+ }
+ return out as unknown as T;
+ }
+ return value;
+}
+
/**
* Storage root with independent schemas per area (optional).
* Usage:
@@ -141,7 +165,7 @@ class ChromeStorage {
set(items: Partial): Promise;
set(items: Partial, callback?: () => void): void | Promise {
const apply = () => {
- Object.assign(this.data, items);
+ Object.assign(this.data, serializeLikeChromeStorage(items));
};
if (callback) {
diff --git a/src/app/repo/network_rule.test.ts b/src/app/repo/network_rule.test.ts
new file mode 100644
index 000000000..763f74491
--- /dev/null
+++ b/src/app/repo/network_rule.test.ts
@@ -0,0 +1,175 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MAX_USER_RULES } from "@App/app/service/service_worker/dnr_rule_ids";
+import {
+ NetworkRuleStateDAO,
+ NetworkRuleStorageError,
+ NetworkRuleStorageReadError,
+ NetworkRuleValidationError,
+ DEFAULT_NETWORK_RULE_STATE,
+ cspRemovalAction,
+ type NetworkRule,
+ type NetworkRuleState,
+ validateNetworkRuleState,
+} from "./network_rule";
+
+function rule(id: string, overrides: Partial = {}): NetworkRule {
+ return {
+ id,
+ name: id,
+ enabled: true,
+ condition: { requestDomains: [`${id}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ };
+}
+
+function state(rules: NetworkRule[], overrides: Partial = {}): NetworkRuleState {
+ return { ...DEFAULT_NETWORK_RULE_STATE, rules, order: rules.map((item) => item.id), ...overrides };
+}
+
+describe("NetworkRuleStateDAO", () => {
+ beforeEach(async () => {
+ await chrome.storage.local.clear();
+ });
+
+ it("缺失 storage key 时使用 revision 0 默认 state 且不写入 storage", async () => {
+ const dao = new NetworkRuleStateDAO();
+ expect(await dao.getState()).toBeUndefined();
+ expect(await chrome.storage.local.get()).toEqual({});
+ expect(DEFAULT_NETWORK_RULE_STATE).toEqual({
+ schemaVersion: 1,
+ revision: 0,
+ masterEnabled: true,
+ rules: [],
+ order: [],
+ });
+ });
+
+ it("storage read 返回 runtime.lastError 时拒绝,而不是伪装成缺失 state", async () => {
+ vi.spyOn(chrome.storage.local, "get").mockImplementationOnce(((
+ _key: string,
+ callback: (result: Record) => void
+ ) => {
+ const lastError = { message: "storage unavailable" };
+ Object.defineProperty(chrome.runtime, "lastError", { configurable: true, value: lastError });
+ callback({});
+ delete (chrome.runtime as unknown as Record).lastError;
+ }) as never);
+
+ await expect(new NetworkRuleStateDAO().getState()).rejects.toBeInstanceOf(NetworkRuleStorageReadError);
+ });
+
+ it("保存后重新读取完整 state 并完成 round-trip", async () => {
+ const dao = new NetworkRuleStateDAO();
+ const saved = state([rule("one")], { revision: 1 });
+ await dao.saveState(saved);
+ expect(await dao.getState()).toEqual(saved);
+ });
+
+ it("storage 往返重排对象键序时保存仍算成功", async () => {
+ const dao = new NetworkRuleStateDAO();
+ const saved = state([rule("one")], { revision: 1 });
+
+ await expect(dao.saveState(saved)).resolves.toBeUndefined();
+
+ const readBack = await dao.getState();
+ expect(readBack).toEqual(saved);
+ // 真实 chrome.storage.local 会重排键序;mock 若退回保序,本用例会退化成同义反复,故在此钉住。
+ expect(Object.keys(readBack!.rules[0])).toEqual([...Object.keys(saved.rules[0])].sort());
+ expect(Object.keys(saved.rules[0])).not.toEqual([...Object.keys(saved.rules[0])].sort());
+ });
+
+ it("storage set 报告 runtime.lastError 时保存失败", async () => {
+ vi.spyOn(chrome.storage.local, "set").mockImplementationOnce(((_items: unknown, callback: () => void) => {
+ const lastError = { message: "QUOTA_BYTES quota exceeded" };
+ Object.defineProperty(chrome.runtime, "lastError", { configurable: true, value: lastError });
+ callback();
+ delete (chrome.runtime as unknown as Record).lastError;
+ }) as never);
+
+ await expect(new NetworkRuleStateDAO().saveState(state([rule("one")], { revision: 1 }))).rejects.toBeInstanceOf(
+ NetworkRuleStorageError
+ );
+ });
+});
+
+describe("网络规则 state 结构校验", () => {
+ it("未知 schema 不会通过完整结构校验", () => {
+ expect(() => validateNetworkRuleState({ schemaVersion: 2 })).toThrowError(NetworkRuleValidationError);
+ });
+
+ it("order 必须是规则 ID 的完整排列", () => {
+ const rules = [rule("a"), rule("b")];
+ expect(() => validateNetworkRuleState(state(rules, { order: ["a"] }))).toThrowError(NetworkRuleValidationError);
+ expect(() => validateNetworkRuleState(state(rules, { order: ["a", "a"] }))).toThrowError(
+ NetworkRuleValidationError
+ );
+ expect(() => validateNetworkRuleState(state(rules, { order: ["a", "ghost"] }))).toThrowError(
+ NetworkRuleValidationError
+ );
+ expect(() => validateNetworkRuleState(state(rules, { order: ["b", "a"] }))).not.toThrow();
+ });
+
+ it("改写请求头时拒绝 Cookie / Authorization / Host / Origin", () => {
+ for (const header of ["cookie", "authorization", "host", "origin"]) {
+ expect(() =>
+ validateNetworkRuleState(
+ state([rule("h", { action: { type: "modifyRequestHeaders", headers: [{ header, operation: "remove" }] } })])
+ )
+ ).toThrowError(NetworkRuleValidationError);
+ }
+ expect(() =>
+ validateNetworkRuleState(
+ state([
+ rule("h", {
+ action: { type: "modifyRequestHeaders", headers: [{ header: "x-flag", operation: "set", value: "1" }] },
+ }),
+ ])
+ )
+ ).not.toThrow();
+ });
+
+ it("移除 CSP 预设携带四个 CSP 头,X-Frame-Options 可选附带", () => {
+ expect(cspRemovalAction()).toEqual({
+ type: "removeResponseHeaders",
+ headers: [
+ "content-security-policy",
+ "content-security-policy-report-only",
+ "x-content-security-policy",
+ "x-webkit-csp",
+ ],
+ });
+ const withXFrameOptions = cspRemovalAction(true);
+ expect(withXFrameOptions.type === "removeResponseHeaders" ? withXFrameOptions.headers : []).toContain(
+ "x-frame-options"
+ );
+ });
+
+ it("条件缺少匹配式、动作字段非法或规则数超过保留段容量时拒绝", () => {
+ expect(() => validateNetworkRuleState(state([rule("n", { condition: {} })]))).toThrowError(
+ NetworkRuleValidationError
+ );
+ expect(() =>
+ validateNetworkRuleState(state([rule("n", { action: { type: "redirect", url: "javascript:0" } })]))
+ ).toThrowError(NetworkRuleValidationError);
+ expect(() =>
+ validateNetworkRuleState(state([rule("n", { action: { type: "redirect", url: "https://example.com/" } })]))
+ ).not.toThrow();
+ expect(() =>
+ validateNetworkRuleState({ ...DEFAULT_NETWORK_RULE_STATE, rules: new Array(MAX_USER_RULES + 1).fill(rule("n")) })
+ ).toThrowError(NetworkRuleValidationError);
+ });
+
+ it("域名必须已规范化且同一列表内不重复", () => {
+ expect(() =>
+ validateNetworkRuleState(state([rule("n", { condition: { requestDomains: ["Example.com"] } })]))
+ ).toThrowError(NetworkRuleValidationError);
+ expect(() =>
+ validateNetworkRuleState(
+ state([rule("n", { condition: { requestDomains: ["a.example.com", "a.example.com"] } })])
+ )
+ ).toThrowError(NetworkRuleValidationError);
+ });
+});
diff --git a/src/app/repo/network_rule.ts b/src/app/repo/network_rule.ts
new file mode 100644
index 000000000..c1d718c41
--- /dev/null
+++ b/src/app/repo/network_rule.ts
@@ -0,0 +1,360 @@
+import {
+ MAX_RULE_DOMAINS,
+ NETWORK_RULE_REQUEST_METHODS,
+ NETWORK_RULE_RESOURCE_TYPES,
+ normalizeRuleDomain,
+ type NetworkRuleRequestMethod,
+ type NetworkRuleResourceType,
+} from "@App/pkg/utils/network_rule_condition";
+import { MAX_USER_RULES } from "@App/app/service/service_worker/dnr_rule_ids";
+import { Repo } from "./repo";
+
+export const NETWORK_RULE_SCHEMA_VERSION = 1 as const;
+export const MAX_RULE_HEADERS = 32;
+export const MAX_RULE_NAME_LENGTH = 80;
+
+/** DNR `RuleCondition` 的受控子集:匹配式(域名列表或 URL 匹配式)、排除项、资源类型、请求方法、发起方域名。 */
+export type NetworkRuleCondition = {
+ requestDomains?: string[];
+ urlFilter?: string;
+ excludedRequestDomains?: string[];
+ initiatorDomains?: string[];
+ excludedInitiatorDomains?: string[];
+ /** 留空表示不限资源类型;编译时展开为含 main_frame 的全部类型,而不是沿用 DNR「除主文档外」的默认。 */
+ resourceTypes?: NetworkRuleResourceType[];
+ requestMethods?: NetworkRuleRequestMethod[];
+};
+
+export const HEADER_OPERATIONS = ["set", "append", "remove"] as const;
+export type NetworkRuleHeaderOperation = (typeof HEADER_OPERATIONS)[number];
+
+export type NetworkRuleHeaderEdit = {
+ header: string;
+ operation: NetworkRuleHeaderOperation;
+ value?: string;
+};
+
+export type NetworkRuleAction =
+ | { type: "removeResponseHeaders"; headers: string[] }
+ | { type: "modifyRequestHeaders"; headers: NetworkRuleHeaderEdit[] }
+ | { type: "modifyResponseHeaders"; headers: NetworkRuleHeaderEdit[] }
+ | { type: "block" }
+ | { type: "redirect"; url: string }
+ | { type: "allow" };
+
+export type NetworkRuleActionType = NetworkRuleAction["type"];
+
+/**
+ * 改写这些请求头会直接泄露或伪造调用者身份,一律在校验阶段拒绝。
+ * 与 GM_xhr 强制过滤 cookie 的既有口径一致(gm_api/gm_api.ts)。
+ */
+export const REQUEST_HEADER_DENYLIST = ["cookie", "authorization", "host", "origin"];
+
+/** 「移除 CSP」预设:固定四个 CSP 响应头,X-Frame-Options 可选附带。 */
+export const CSP_RESPONSE_HEADERS = [
+ "content-security-policy",
+ "content-security-policy-report-only",
+ "x-content-security-policy",
+ "x-webkit-csp",
+];
+export const X_FRAME_OPTIONS_HEADER = "x-frame-options";
+
+export function cspRemovalAction(includeXFrameOptions = false): NetworkRuleAction {
+ return {
+ type: "removeResponseHeaders",
+ headers: includeXFrameOptions ? [...CSP_RESPONSE_HEADERS, X_FRAME_OPTIONS_HEADER] : [...CSP_RESPONSE_HEADERS],
+ };
+}
+
+export type NetworkRule = {
+ id: string;
+ name: string;
+ enabled: boolean;
+ condition: NetworkRuleCondition;
+ action: NetworkRuleAction;
+ createdAt: number;
+ updatedAt: number;
+};
+
+export type NetworkRuleState = {
+ schemaVersion: typeof NETWORK_RULE_SCHEMA_VERSION;
+ revision: number;
+ masterEnabled: boolean;
+ rules: NetworkRule[];
+ /** 规则 ID 的完整排列,自上而下即优先级自高到低。 */
+ order: string[];
+};
+
+export class NetworkRuleValidationError extends Error {
+ constructor(
+ public readonly path: string,
+ public readonly messageKey: string
+ ) {
+ super(messageKey);
+ this.name = "NetworkRuleValidationError";
+ }
+}
+
+export class NetworkRuleStorageError extends Error {
+ constructor() {
+ super("storage_write_failed");
+ this.name = "NetworkRuleStorageError";
+ }
+}
+
+export class NetworkRuleStorageReadError extends Error {
+ constructor(message = "storage_read_failed") {
+ super(message);
+ this.name = "NetworkRuleStorageReadError";
+ }
+}
+
+export const DEFAULT_NETWORK_RULE_STATE: NetworkRuleState = {
+ schemaVersion: NETWORK_RULE_SCHEMA_VERSION,
+ revision: 0,
+ masterEnabled: true,
+ rules: [],
+ order: [],
+};
+
+const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
+
+export function isValidHeaderName(value: unknown): value is string {
+ return typeof value === "string" && HEADER_NAME_PATTERN.test(value) && value === value.toLowerCase();
+}
+
+export function isDeniedRequestHeader(header: string): boolean {
+ return REQUEST_HEADER_DENYLIST.includes(header.trim().toLowerCase());
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function validateDomainList(value: unknown, path: string): void {
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_RULE_DOMAINS) {
+ throw new NetworkRuleValidationError(path, "domain_count_invalid");
+ }
+ const seen = new Set();
+ for (const [index, domain] of value.entries()) {
+ let normalized: string;
+ try {
+ normalized = typeof domain === "string" ? normalizeRuleDomain(domain) : "";
+ } catch {
+ throw new NetworkRuleValidationError(`${path}[${index}]`, "domain_invalid");
+ }
+ if (normalized !== domain) throw new NetworkRuleValidationError(`${path}[${index}]`, "domain_invalid");
+ if (seen.has(normalized)) throw new NetworkRuleValidationError(`${path}[${index}]`, "domain_duplicate");
+ seen.add(normalized);
+ }
+}
+
+function validateEnumList(value: unknown, allowed: readonly string[], path: string, messageKey: string): void {
+ if (!Array.isArray(value) || value.length === 0) throw new NetworkRuleValidationError(path, messageKey);
+ const seen = new Set();
+ for (const [index, item] of value.entries()) {
+ if (typeof item !== "string" || !allowed.includes(item) || seen.has(item)) {
+ throw new NetworkRuleValidationError(`${path}[${index}]`, messageKey);
+ }
+ seen.add(item);
+ }
+}
+
+function validateCondition(condition: unknown, path: string): void {
+ if (!isRecord(condition)) throw new NetworkRuleValidationError(path, "condition_invalid");
+ const hasDomains = condition.requestDomains !== undefined;
+ const hasUrlFilter = condition.urlFilter !== undefined;
+ if (!hasDomains && !hasUrlFilter) throw new NetworkRuleValidationError(path, "condition_match_required");
+ if (hasUrlFilter) {
+ const urlFilter = condition.urlFilter;
+ if (typeof urlFilter !== "string" || !urlFilter.trim() || /[\s]/.test(urlFilter)) {
+ throw new NetworkRuleValidationError(`${path}.urlFilter`, "url_filter_invalid");
+ }
+ }
+ for (const key of ["requestDomains", "excludedRequestDomains", "initiatorDomains", "excludedInitiatorDomains"]) {
+ if (condition[key] !== undefined) validateDomainList(condition[key], `${path}.${key}`);
+ }
+ if (condition.resourceTypes !== undefined) {
+ validateEnumList(
+ condition.resourceTypes,
+ NETWORK_RULE_RESOURCE_TYPES,
+ `${path}.resourceTypes`,
+ "resource_type_invalid"
+ );
+ }
+ if (condition.requestMethods !== undefined) {
+ validateEnumList(
+ condition.requestMethods,
+ NETWORK_RULE_REQUEST_METHODS,
+ `${path}.requestMethods`,
+ "request_method_invalid"
+ );
+ }
+}
+
+function validateHeaderNames(value: unknown, path: string): void {
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_RULE_HEADERS) {
+ throw new NetworkRuleValidationError(path, "header_count_invalid");
+ }
+ const seen = new Set();
+ for (const [index, header] of value.entries()) {
+ if (!isValidHeaderName(header)) throw new NetworkRuleValidationError(`${path}[${index}]`, "header_name_invalid");
+ if (seen.has(header)) throw new NetworkRuleValidationError(`${path}[${index}]`, "header_duplicate");
+ seen.add(header);
+ }
+}
+
+function validateHeaderEdits(value: unknown, path: string, denyRequestHeaders: boolean): void {
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_RULE_HEADERS) {
+ throw new NetworkRuleValidationError(path, "header_count_invalid");
+ }
+ for (const [index, entry] of value.entries()) {
+ const itemPath = `${path}[${index}]`;
+ if (!isRecord(entry) || !isValidHeaderName(entry.header)) {
+ throw new NetworkRuleValidationError(itemPath, "header_name_invalid");
+ }
+ if (denyRequestHeaders && isDeniedRequestHeader(entry.header)) {
+ throw new NetworkRuleValidationError(itemPath, "header_forbidden");
+ }
+ if (typeof entry.operation !== "string" || !HEADER_OPERATIONS.includes(entry.operation as never)) {
+ throw new NetworkRuleValidationError(`${itemPath}.operation`, "header_operation_invalid");
+ }
+ if (entry.operation === "remove") {
+ if (entry.value !== undefined) throw new NetworkRuleValidationError(`${itemPath}.value`, "header_value_invalid");
+ } else if (typeof entry.value !== "string" || !entry.value) {
+ throw new NetworkRuleValidationError(`${itemPath}.value`, "header_value_required");
+ }
+ }
+}
+
+function validateAction(action: unknown, path: string): void {
+ if (!isRecord(action)) throw new NetworkRuleValidationError(path, "action_invalid");
+ switch (action.type) {
+ case "removeResponseHeaders":
+ validateHeaderNames(action.headers, `${path}.headers`);
+ return;
+ case "modifyRequestHeaders":
+ validateHeaderEdits(action.headers, `${path}.headers`, true);
+ return;
+ case "modifyResponseHeaders":
+ validateHeaderEdits(action.headers, `${path}.headers`, false);
+ return;
+ case "block":
+ case "allow":
+ return;
+ case "redirect": {
+ if (typeof action.url !== "string") throw new NetworkRuleValidationError(`${path}.url`, "redirect_url_invalid");
+ let url: URL;
+ try {
+ url = new URL(action.url);
+ } catch {
+ throw new NetworkRuleValidationError(`${path}.url`, "redirect_url_invalid");
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new NetworkRuleValidationError(`${path}.url`, "redirect_url_invalid");
+ }
+ return;
+ }
+ default:
+ throw new NetworkRuleValidationError(path, "action_invalid");
+ }
+}
+
+function validateTimestamp(value: unknown, path: string): void {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
+ throw new NetworkRuleValidationError(path, "timestamp_invalid");
+ }
+}
+
+export function validateNetworkRuleState(value: unknown): asserts value is NetworkRuleState {
+ if (!isRecord(value) || value.schemaVersion !== NETWORK_RULE_SCHEMA_VERSION) {
+ throw new NetworkRuleValidationError("schemaVersion", "unsupported_schema");
+ }
+ const { revision, masterEnabled, rules, order } = value;
+ if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) {
+ throw new NetworkRuleValidationError("revision", "revision_invalid");
+ }
+ if (typeof masterEnabled !== "boolean") {
+ throw new NetworkRuleValidationError("masterEnabled", "boolean_invalid");
+ }
+ if (!Array.isArray(rules) || rules.length > MAX_USER_RULES) {
+ throw new NetworkRuleValidationError("rules", "rule_count_invalid");
+ }
+
+ const ids = new Set();
+ for (const [index, rawRule] of rules.entries()) {
+ const path = `rules[${index}]`;
+ if (!isRecord(rawRule)) throw new NetworkRuleValidationError(path, "rule_invalid");
+ if (typeof rawRule.id !== "string" || !rawRule.id || ids.has(rawRule.id)) {
+ throw new NetworkRuleValidationError(`${path}.id`, "rule_id_invalid");
+ }
+ ids.add(rawRule.id);
+ if (
+ typeof rawRule.name !== "string" ||
+ !rawRule.name.trim() ||
+ Array.from(rawRule.name).length > MAX_RULE_NAME_LENGTH
+ ) {
+ throw new NetworkRuleValidationError(`${path}.name`, "rule_name_invalid");
+ }
+ if (typeof rawRule.enabled !== "boolean")
+ throw new NetworkRuleValidationError(`${path}.enabled`, "boolean_invalid");
+ validateTimestamp(rawRule.createdAt, `${path}.createdAt`);
+ validateTimestamp(rawRule.updatedAt, `${path}.updatedAt`);
+ validateCondition(rawRule.condition, `${path}.condition`);
+ validateAction(rawRule.action, `${path}.action`);
+ }
+
+ if (!Array.isArray(order) || order.length !== ids.size) {
+ throw new NetworkRuleValidationError("order", "order_invalid");
+ }
+ const ordered = new Set();
+ for (const [index, id] of order.entries()) {
+ if (typeof id !== "string" || !ids.has(id) || ordered.has(id)) {
+ throw new NetworkRuleValidationError(`order[${index}]`, "order_invalid");
+ }
+ ordered.add(id);
+ }
+}
+
+export class NetworkRuleStateDAO extends Repo {
+ private mutationQueue: Promise = Promise.resolve();
+
+ constructor() {
+ super("network_rule");
+ }
+
+ getState(): Promise {
+ const key = this.joinKey("state");
+ return new Promise((resolve, reject) => {
+ chrome.storage.local.get(key, (result) => {
+ const lastError = chrome.runtime.lastError;
+ if (lastError) {
+ reject(new NetworkRuleStorageReadError(lastError.message));
+ return;
+ }
+ resolve(result[key] as NetworkRuleState | undefined);
+ });
+ });
+ }
+
+ runExclusive(operation: () => Promise): Promise {
+ const next = this.mutationQueue.then(operation);
+ this.mutationQueue = next.then(
+ () => undefined,
+ () => undefined
+ );
+ return next;
+ }
+
+ // 不做写后回读自证:chrome.storage.local 的真实失败(配额、IO)都经 runtime.lastError 变成 _save 的
+ // 拒绝,而回读只能看到序列化后的等价副本——真实浏览器里它的键序与写入时不同,回读比较反而把成功的
+ // 写入判成失败(见 PR #1598 继承的 csp_rule.ts)。
+ async saveState(state: NetworkRuleState): Promise {
+ validateNetworkRuleState(state);
+ try {
+ await this._save("state", state);
+ } catch {
+ throw new NetworkRuleStorageError();
+ }
+ }
+}
diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts
index aad7eb5c7..78e886aca 100644
--- a/src/app/service/service_worker/client.ts
+++ b/src/app/service/service_worker/client.ts
@@ -34,6 +34,19 @@ import { type TSetValuesParams } from "./value";
import type { LocalBackupExport } from "./synchronize";
import type { ExternalAccessUIService } from "./external_access/service";
import type { WSEnvelope } from "./external_access/types";
+import type {
+ NetworkRuleMutationResult,
+ NetworkRuleCreateInput,
+ NetworkRuleDeleteInput,
+ NetworkRuleEnabledInput,
+ NetworkRuleMasterEnabledInput,
+ NetworkRuleReorderInput,
+ NetworkRuleServiceError,
+ NetworkRuleSnapshot,
+ NetworkRuleUpdateInput,
+} from "./network_rule";
+
+export type { NetworkRuleServiceError } from "./network_rule";
export class ServiceWorkerClient extends Client {
constructor(msgSender: MessageSend) {
@@ -45,6 +58,98 @@ export class ServiceWorkerClient extends Client {
}
}
+export function parseNetworkRuleError(error: unknown): NetworkRuleServiceError {
+ const payload =
+ typeof error === "string"
+ ? (() => {
+ try {
+ return JSON.parse(error) as unknown;
+ } catch {
+ return undefined;
+ }
+ })()
+ : error;
+ if (payload && typeof payload === "object" && "code" in payload) {
+ const code = payload.code;
+ if (
+ code === "invalid_input" ||
+ code === "not_found" ||
+ code === "revision_conflict" ||
+ code === "storage_read_failed" ||
+ code === "storage_write_failed" ||
+ code === "unsupported_schema"
+ ) {
+ return payload as NetworkRuleServiceError;
+ }
+ }
+ return { code: "storage_write_failed" };
+}
+
+/**
+ * chrome.runtime.sendMessage 的响应通道在 service worker 处理耗时较长时可能被浏览器丢弃
+ * (例如用户填写表单期间 service worker 进入空闲状态)。此时请求本身可能已在后台成功执行,
+ * 只是响应没能送达,与服务端明确返回的失败无法用同一错误区分,因此单独抛出,交由调用方
+ * 通过重新拉取 state 来判断该次操作究竟是否已生效。
+ */
+export class NetworkRuleAmbiguousResponseError extends Error {
+ constructor() {
+ super("network rule response lost in transit");
+ this.name = "NetworkRuleAmbiguousResponseError";
+ }
+}
+
+export class NetworkRuleClient extends Client {
+ constructor(msgSender: MessageSend) {
+ super(msgSender, "serviceWorker/networkRule");
+ }
+
+ private async request(action: string, data?: unknown): Promise {
+ let result: T | undefined;
+ try {
+ result = await this.do(action, data);
+ } catch (error) {
+ throw parseNetworkRuleError(error);
+ }
+ if (result === undefined) {
+ if (action === "getState") throw parseNetworkRuleError(undefined);
+ throw new NetworkRuleAmbiguousResponseError();
+ }
+ return result;
+ }
+
+ getState(): Promise {
+ return this.request("getState");
+ }
+
+ createRule(input: NetworkRuleCreateInput): Promise {
+ return this.request("createRule", input);
+ }
+
+ updateRule(input: NetworkRuleUpdateInput): Promise {
+ return this.request("updateRule", input);
+ }
+
+ deleteRules(input: NetworkRuleDeleteInput): Promise {
+ return this.request("deleteRules", input);
+ }
+
+ setRulesEnabled(input: NetworkRuleEnabledInput): Promise {
+ return this.request("setRulesEnabled", input);
+ }
+
+ setMasterEnabled(input: NetworkRuleMasterEnabledInput): Promise {
+ return this.request("setMasterEnabled", input);
+ }
+
+ reorderRules(input: NetworkRuleReorderInput): Promise {
+ return this.request("reorderRules", input);
+ }
+
+ retryApply(): Promise {
+ return this.request("retryApply");
+ }
+}
+
export class ScriptClient extends Client {
constructor(msgSender: MessageSend) {
super(msgSender, "serviceWorker/script");
diff --git a/src/app/service/service_worker/dnr_rule_ids.ts b/src/app/service/service_worker/dnr_rule_ids.ts
new file mode 100644
index 000000000..51fda8531
--- /dev/null
+++ b/src/app/service/service_worker/dnr_rule_ids.ts
@@ -0,0 +1,77 @@
+/**
+ * declarativeNetRequest 规则 ID 与 priority 的全仓登记表。
+ *
+ * 动态规则与会话规则的 ID 各自独立,但在匹配阶段共同参与裁决,因此两类 ID 都登记在此,
+ * 新增规则前先在这里占位,避免再出现「两个功能撞同一个写死 ID」。当前占用:
+ *
+ * | 作用域 | ID | 用途 |
+ * | -------- | --------------- | ------------------------------------------------- |
+ * | dynamic | 1 | 安装重定向规则集的整体清理位(script.ts) |
+ * | dynamic | 2 | 单次安装失败后放行该 URL(script.ts) |
+ * | session | 1000 - 1099 | .user.js / .cat.md 安装重定向(script.ts) |
+ * | session | 1100 - 1199 | 上面每条重定向对应的请求阶段守卫(script.ts) |
+ * | dynamic | 2001 - 2520 | 预留给 PR #1264,尚未落地 |
+ * | session | 999 | GM_xhr 请求标记头清理(gm_api/mv3_utils.ts) |
+ * | session | > 10000 | GM_xhr 单次请求头改写(gm_api/dnr_id_controller.ts) |
+ *
+ * 用户网络规则因此从 1_000_000 起分配:既避开上面全部区段,也避开安装入口随版本增加而
+ * 向上生长的空间。
+ */
+
+export const USER_RULE_ID_MIN = 1_000_000;
+export const USER_RULE_ID_MAX = 1_099_999;
+
+/** 保留段容量即用户规则条数的结构性上界;真正的上界是浏览器动态规则配额,两者中前者更大。 */
+export const MAX_USER_RULES = USER_RULE_ID_MAX - USER_RULE_ID_MIN + 1;
+
+/** 安装重定向按条件下标依次占位。 */
+export const INSTALL_REDIRECT_RULE_ID_MIN = 1000;
+export const INSTALL_REDIRECT_RULE_ID_MAX = 1099;
+
+/** 请求阶段守卫与安装重定向一一对应,用同一个下标,段与段之间留出等宽空间。 */
+export const INSTALL_GUARD_RULE_ID_MIN = 1100;
+export const INSTALL_GUARD_RULE_ID_MAX = 1199;
+
+/**
+ * ScriptCat 自身注册的规则统一使用的优先级。
+ *
+ * DNR 先比 priority,只有 priority 相同才按 allow > block > upgradeScheme > redirect 的动作序裁决。
+ * 用户规则的优先级由列表位次映射,上界是 MAX_USER_RULES,严格低于此值,因此在同一个裁决阶段里
+ * 用户规则不可能抢在 .user.js 安装重定向或 GM_xhr 头改写之前生效。
+ */
+export const INTERNAL_DNR_PRIORITY = 1_000_000;
+
+/**
+ * 请求阶段守卫的优先级:严格高于用户段、严格低于 INTERNAL_DNR_PRIORITY。
+ *
+ * 两侧的边界都是必需的。高于用户段,用户的 block 才压不过它;低于安装重定向,重定向才仍然
+ * 「优先级高于所有命中的 allow」而得以执行——DNR 只把优先级高于命中 allow 的规则纳入裁决,
+ * 若守卫与重定向同级,同级动作序 allow > redirect 会让安装页反而打不开。
+ */
+export const INTERNAL_DNR_GUARD_PRIORITY = INTERNAL_DNR_PRIORITY - 1;
+
+export function isUserRuleId(id: number): boolean {
+ return id >= USER_RULE_ID_MIN && id <= USER_RULE_ID_MAX;
+}
+
+/**
+ * 为每条安装重定向条件生成一条请求阶段的 allow 规则。
+ *
+ * Chrome ≥128 上安装重定向带 Content-Type 响应头条件,只能在响应头阶段裁决;而用户的 block
+ * 在请求阶段就短路了请求,响应根本不存在,跨阶段无从比较 priority——运行时验证已观察到用户
+ * 的一条 block 规则使 `.user.js` 导航变成 ERR_BLOCKED_BY_CLIENT。守卫在请求阶段先放行同一批
+ * 请求,使其活到响应阶段交由重定向裁决。代价是用户规则不再能作用于 `.user.js` 请求本身。
+ *
+ * 条件逐字取自传入的重定向条件(调用方须在附加 responseHeaders 之前调用),守卫因此不会豁免
+ * 比重定向更宽的请求。
+ */
+export function buildInstallGuardRules(
+ conditions: chrome.declarativeNetRequest.RuleCondition[]
+): chrome.declarativeNetRequest.Rule[] {
+ return conditions.map((condition, idx) => ({
+ id: INSTALL_GUARD_RULE_ID_MIN + idx,
+ priority: INTERNAL_DNR_GUARD_PRIORITY,
+ action: { type: "allow" as chrome.declarativeNetRequest.RuleActionType },
+ condition: { ...condition },
+ }));
+}
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 99a5e2b68..36d0c6eed 100644
--- a/src/app/service/service_worker/gm_api/gm_api.ts
+++ b/src/app/service/service_worker/gm_api/gm_api.ts
@@ -78,6 +78,7 @@ import { nextSessionRuleId, removeSessionRuleIdEntry } from "./dnr_id_controller
import type { DownloadCallback } from "../download";
import { detachDownloadCallback, startDownload } from "../download";
import { isRequestInitiatorOriginMatched, gmXhrRequestLinker, type IWebRequestDetails } from "./mv3_utils";
+import { INTERNAL_DNR_PRIORITY } from "../dnr_rule_ids";
let generatedUniqueMarkerIDs = "";
let generatedUniqueMarkerIDWhen = "";
@@ -818,7 +819,7 @@ export default class GMApi {
type: "modifyHeaders",
requestHeaders: modifyReqHeaders,
},
- priority: 1,
+ priority: INTERNAL_DNR_PRIORITY,
condition: {
resourceTypes: ["xmlhttprequest"],
urlFilter: params.url,
diff --git a/src/app/service/service_worker/gm_api/mv3_utils.ts b/src/app/service/service_worker/gm_api/mv3_utils.ts
index b2d387930..709f95cff 100644
--- a/src/app/service/service_worker/gm_api/mv3_utils.ts
+++ b/src/app/service/service_worker/gm_api/mv3_utils.ts
@@ -2,6 +2,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue";
import { isFirefox } from "@App/pkg/utils/utils";
import { type FetchXHR } from "@App/pkg/utils/xhr/fetch_xhr";
import { scXhrRequests } from "./gm_xhr";
+import { INTERNAL_DNR_PRIORITY } from "../dnr_rule_ids";
const bFirefox = isFirefox();
type TbgMarkerMapEntry = {
@@ -169,7 +170,7 @@ export class ChromiumHeaderMarkerLinker implements GmXhrRequestLinker {
},
] satisfies chrome.declarativeNetRequest.ModifyHeaderInfo[],
},
- priority: 1,
+ priority: INTERNAL_DNR_PRIORITY,
condition: {
resourceTypes: ["xmlhttprequest"],
tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen
diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts
index 019857ffe..f32318dec 100644
--- a/src/app/service/service_worker/index.ts
+++ b/src/app/service/service_worker/index.ts
@@ -35,6 +35,9 @@ import { ExternalAccessController } from "@App/app/service/service_worker/extern
import { ExternalAccessUIService } from "@App/app/service/service_worker/external_access/service";
import { ExternalAccessConnectClient } from "@App/app/service/offscreen/client";
import { hookFirefoxEventPageKeepAliveLoop, hookServiceWorkerKeepAliveLoop } from "../offscreen/keep_alive";
+import { NetworkRuleStateDAO } from "@App/app/repo/network_rule";
+import { NetworkRuleService } from "./network_rule";
+import { DeclarativeNetRequestUserRuleApplier, compileNetworkRules } from "./network_rule_compiler";
// "直接允许" 写策略下 MCP 无需人工确认即执行了写操作,发系统通知让用户知晓(决策 #12 的知情兜底)。
// kind=update 只由 scripts.edit.request 产生,故文案按「编辑」而非版本更新描述,避免被误读为例行升级。
@@ -149,6 +152,16 @@ export default class ServiceWorkerManager {
faviconDAO
);
system.init();
+
+ const networkRule = new NetworkRuleService(
+ this.api.group("networkRule"),
+ this.mq,
+ new NetworkRuleStateDAO(),
+ compileNetworkRules,
+ new DeclarativeNetRequestUserRuleApplier()
+ );
+ networkRule.init();
+
const agent = new AgentService(this.api.group("agent"), this.offscreenSend, resource);
agent.init();
diff --git a/src/app/service/service_worker/network_rule.test.ts b/src/app/service/service_worker/network_rule.test.ts
new file mode 100644
index 000000000..388a5c3bb
--- /dev/null
+++ b/src/app/service/service_worker/network_rule.test.ts
@@ -0,0 +1,433 @@
+import { beforeEach, describe, expect, it, vi, type Mocked } from "vitest";
+import type { Group } from "@Packages/message/server";
+import type { IMessageQueue } from "@Packages/message/message_queue";
+import {
+ MAX_RULE_NAME_LENGTH,
+ NetworkRuleStateDAO,
+ NetworkRuleStorageReadError,
+ DEFAULT_NETWORK_RULE_STATE,
+ cspRemovalAction,
+ type NetworkRuleState,
+} from "@App/app/repo/network_rule";
+import { NetworkRuleService, type NetworkRuleApplier } from "./network_rule";
+import { compileNetworkRules, DeclarativeNetRequestUserRuleApplier } from "./network_rule_compiler";
+
+type Handler = (params?: unknown) => Promise;
+
+const cspRule = { enabled: true, condition: { requestDomains: ["example.com"] }, action: cspRemovalAction() };
+
+function createHarness>(
+ initialState?: NetworkRuleState,
+ getStateError?: unknown,
+ applier: A = { apply: vi.fn(async () => {}) } as unknown as A
+) {
+ const handlers = new Map();
+ const group = {
+ on: vi.fn((name: string, handler: Handler) => handlers.set(name, handler)),
+ } as unknown as Group;
+ const queue = {
+ publish: vi.fn(),
+ } as unknown as IMessageQueue;
+ const dao = {
+ state: initialState,
+ getState: vi.fn(async () => {
+ if (getStateError) throw getStateError;
+ return dao.state;
+ }),
+ saveState: vi.fn(async (state: NetworkRuleState) => {
+ dao.state = state;
+ return state;
+ }),
+ } as unknown as NetworkRuleStateDAO & { state: NetworkRuleState | undefined };
+ let daoMutationQueue = Promise.resolve();
+ (dao as NetworkRuleStateDAO & { runExclusive: (operation: () => Promise) => Promise }).runExclusive = (
+ operation: () => Promise
+ ) => {
+ const next = daoMutationQueue.then(operation);
+ daoMutationQueue = next.then(
+ () => undefined,
+ () => undefined
+ );
+ return next;
+ };
+ const service = new NetworkRuleService(group, queue, dao, compileNetworkRules, applier);
+ service.init();
+ return { service, handlers, group, queue, dao, applier };
+}
+
+/** 批量用例都需要一批真实创建出来的规则:createRule 每次推进 revision,接力返回最新值。 */
+async function seedRules(handlers: Map, count: number) {
+ let revision = 0;
+ const ids: string[] = [];
+ for (let index = 0; index < count; index += 1) {
+ const created = (await handlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: revision,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ })) as { state: NetworkRuleState };
+ revision = created.state.revision;
+ ids.push(created.state.rules.at(-1)!.id);
+ }
+ return { revision, ids };
+}
+
+describe("NetworkRuleService", () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it("初始化时按持久化 state 重建规则", async () => {
+ const state: NetworkRuleState = {
+ ...DEFAULT_NETWORK_RULE_STATE,
+ revision: 4,
+ rules: [
+ {
+ id: "one",
+ name: "example.com",
+ enabled: true,
+ condition: { requestDomains: ["example.com"] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ ],
+ order: ["one"],
+ };
+ const { handlers, applier } = createHarness(state);
+ const snapshot = await handlers.get("getState")!();
+ expect(snapshot).toMatchObject({ state, apply: { state: "applied", revision: 4 } });
+ expect(applier.apply).toHaveBeenCalledWith(compileNetworkRules(state));
+ });
+
+ it("创建规则会规范化输入、追加到顺序末尾并发布新的 snapshot", async () => {
+ const { handlers, queue, dao } = createHarness();
+ const initial = (await handlers.get("getState")!()) as { state: NetworkRuleState };
+ const result = (await handlers.get("createRule")!({
+ baseRevision: initial.state.revision,
+ name: "Example",
+ enabled: true,
+ condition: { requestDomains: ["https://Example.com/path"] },
+ action: { type: "removeResponseHeaders", headers: ["Content-Security-Policy"] },
+ })) as { outcome: string; state: NetworkRuleState };
+ expect(result.outcome).toBe("applied");
+ expect(result.state.revision).toBe(1);
+ expect(result.state.rules[0].condition).toEqual({ requestDomains: ["example.com"] });
+ expect(result.state.rules[0].action).toEqual({
+ type: "removeResponseHeaders",
+ headers: ["content-security-policy"],
+ });
+ expect(result.state.order).toEqual([result.state.rules[0].id]);
+ expect(dao.saveState).toHaveBeenCalledOnce();
+ expect(queue.publish).toHaveBeenCalledWith(
+ "networkRule/stateChanged",
+ expect.objectContaining({ state: result.state })
+ );
+ });
+
+ it("改写请求头命中黑名单时拒绝保存", async () => {
+ const { handlers, dao } = createHarness();
+ const initial = (await handlers.get("getState")!()) as { state: NetworkRuleState };
+ await expect(
+ handlers.get("createRule")!({
+ baseRevision: initial.state.revision,
+ enabled: true,
+ condition: { requestDomains: ["example.com"] },
+ action: { type: "modifyRequestHeaders", headers: [{ header: "Cookie", operation: "remove" }] },
+ })
+ ).rejects.toMatchObject({ code: "invalid_input", messageKey: "header_forbidden" });
+ expect(dao.saveState).not.toHaveBeenCalled();
+ });
+
+ it("排序按新顺序重编译,非排列的顺序被拒绝", async () => {
+ const { handlers, applier } = createHarness();
+ let revision = 0;
+ const ids: string[] = [];
+ for (const domain of ["a.example.com", "b.example.com"]) {
+ const created = (await handlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: revision,
+ condition: { requestDomains: [domain] },
+ })) as { state: NetworkRuleState };
+ revision = created.state.revision;
+ ids.push(created.state.rules.at(-1)!.id);
+ }
+
+ await expect(handlers.get("reorderRules")!({ baseRevision: revision, order: [ids[0]] })).rejects.toMatchObject({
+ code: "invalid_input",
+ messageKey: "order_invalid",
+ });
+
+ const reordered = (await handlers.get("reorderRules")!({
+ baseRevision: revision,
+ order: [ids[1], ids[0]],
+ })) as { outcome: string; state: NetworkRuleState };
+ expect(reordered.outcome).toBe("applied");
+ expect(reordered.state.order).toEqual([ids[1], ids[0]]);
+ const compiled = applier.apply.mock.calls.at(-1)![0];
+ expect(compiled.map((item) => item.condition.requestDomains)).toEqual([["b.example.com"], ["a.example.com"]]);
+ expect(compiled[0].priority).toBeGreaterThan(compiled[1].priority!);
+ });
+
+ it("批量删除三条只写一次 state、只更新一次动态规则,并同步移出顺序数组", async () => {
+ const dnr = chrome.declarativeNetRequest as typeof chrome.declarativeNetRequest & { resetMock(): void };
+ dnr.resetMock();
+ const { handlers, dao } = createHarness(undefined, undefined, new DeclarativeNetRequestUserRuleApplier());
+ const { revision, ids } = await seedRules(handlers, 3);
+ const update = vi.spyOn(chrome.declarativeNetRequest, "updateDynamicRules");
+ const writesBefore = vi.mocked(dao.saveState).mock.calls.length;
+
+ const deleted = (await handlers.get("deleteRules")!({ baseRevision: revision, ids: [ids[0], ids[2]] })) as {
+ state: NetworkRuleState;
+ };
+
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(dao.saveState).mock.calls.length - writesBefore).toBe(1);
+ expect(deleted.state.rules.map((rule) => rule.id)).toEqual([ids[1]]);
+ expect(deleted.state.order).toEqual([ids[1]]);
+ expect(deleted.state.revision).toBe(revision + 1);
+ });
+
+ it("批量删除被拒绝时一条都不删除,顺序数组保持原样", async () => {
+ const { handlers, dao, applier } = createHarness();
+ const { revision, ids } = await seedRules(handlers, 3);
+ const before = dao.state;
+ const applies = applier.apply.mock.calls.length;
+
+ await expect(handlers.get("deleteRules")!({ baseRevision: revision - 1, ids })).rejects.toMatchObject({
+ code: "revision_conflict",
+ });
+ await expect(
+ handlers.get("deleteRules")!({ baseRevision: revision, ids: [ids[0], "missing"] })
+ ).rejects.toMatchObject({ code: "not_found" });
+
+ expect(dao.state).toBe(before);
+ expect(dao.state!.rules.map((rule) => rule.id)).toEqual(ids);
+ expect(dao.state!.order).toEqual(ids);
+ expect(applier.apply.mock.calls.length).toBe(applies);
+ });
+
+ it("批量启用只写入需要改变的规则,全部已是目标状态时不写入", async () => {
+ const { handlers, dao } = createHarness();
+ const { revision, ids } = await seedRules(handlers, 3);
+ const writesBefore = vi.mocked(dao.saveState).mock.calls.length;
+
+ const disabled = (await handlers.get("setRulesEnabled")!({
+ baseRevision: revision,
+ ids: [ids[0], ids[1]],
+ enabled: false,
+ })) as { state: NetworkRuleState };
+ expect(disabled.state.rules.map((rule) => rule.enabled)).toEqual([false, false, true]);
+ expect(vi.mocked(dao.saveState).mock.calls.length - writesBefore).toBe(1);
+
+ const again = (await handlers.get("setRulesEnabled")!({
+ baseRevision: disabled.state.revision,
+ ids: [ids[0], ids[1]],
+ enabled: false,
+ })) as { state: NetworkRuleState };
+ expect(again.state.revision).toBe(disabled.state.revision);
+ expect(vi.mocked(dao.saveState).mock.calls.length - writesBefore).toBe(1);
+ });
+
+ it("状态广播失败时仍返回已保存的 mutation 结果", async () => {
+ const { handlers, queue, dao } = createHarness();
+ vi.mocked(queue.publish).mockImplementation(() => {
+ throw new Error("receiver unavailable");
+ });
+ const result = await handlers.get("createRule")!({ ...cspRule, baseRevision: 0, name: "Example" });
+
+ expect(result).toMatchObject({ outcome: "applied", state: { revision: 1 } });
+ expect(dao.state?.rules).toHaveLength(1);
+ });
+
+ it("baseRevision 过期时返回 revision conflict 且不覆盖其他页面的修改", async () => {
+ const { handlers, dao, applier } = createHarness();
+ await handlers.get("createRule")!({ ...cspRule, baseRevision: 0 });
+ const before = dao.state;
+ await expect(handlers.get("deleteRules")!({ baseRevision: 0, ids: [before!.rules[0].id] })).rejects.toMatchObject({
+ code: "revision_conflict",
+ });
+ expect(dao.state).toBe(before);
+ expect(applier.apply).toHaveBeenCalledTimes(2);
+ });
+
+ it("DNR 更新失败时保存 state 并返回可重试错误,retryApply 不增加 revision", async () => {
+ const { handlers, applier } = createHarness();
+ await handlers.get("getState")!();
+ applier.apply.mockRejectedValueOnce(new Error("permission denied"));
+ const failed = (await handlers.get("createRule")!({ ...cspRule, baseRevision: 0 })) as {
+ outcome: string;
+ apply: { state: string; desiredRevision: number };
+ };
+ expect(failed.outcome).toBe("apply-error");
+ expect(failed.apply).toMatchObject({ state: "error", desiredRevision: 1, lastAppliedRevision: 0 });
+ applier.apply.mockResolvedValueOnce();
+ const retried = (await handlers.get("retryApply")!()) as { outcome: string; state: NetworkRuleState };
+ expect(retried.outcome).toBe("applied");
+ expect(retried.state.revision).toBe(1);
+ });
+
+ it("未知 schema 保留数据并返回 unsupported_schema", async () => {
+ const { handlers, applier } = createHarness({ schemaVersion: 2 } as unknown as NetworkRuleState);
+ await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "unsupported_schema" });
+ expect(applier.apply).toHaveBeenCalledWith([]);
+ });
+
+ it("storage read 失败时返回 storage_read_failed 且不清空已应用规则", async () => {
+ const { handlers, applier } = createHarness(undefined, new NetworkRuleStorageReadError());
+
+ await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "storage_read_failed" });
+ expect(applier.apply).not.toHaveBeenCalled();
+ });
+
+ it("未命名规则的默认名取自匹配式,超长时截断到名称上限而不是连带拒绝整条规则", async () => {
+ const { handlers } = createHarness();
+ const urlFilter = `https://example.com/${"a".repeat(200)}`;
+ const created = (await handlers.get("createRule")!({
+ baseRevision: 0,
+ enabled: true,
+ condition: { urlFilter },
+ action: { type: "block" },
+ })) as { state: NetworkRuleState };
+
+ expect(created.state.rules[0].name).toBe(urlFilter.slice(0, MAX_RULE_NAME_LENGTH));
+ });
+
+ it("retryApply 重新初始化期间的 getState 等待恢复结果,不会看到「既无错误也无 state」的中间态", async () => {
+ const { handlers, dao, applier } = createHarness({ schemaVersion: 2 } as unknown as NetworkRuleState);
+ await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "unsupported_schema" });
+
+ dao.state = undefined;
+ let release!: () => void;
+ const reconciling = new Promise((entered) => {
+ applier.apply.mockImplementationOnce(() => {
+ entered();
+ return new Promise((done) => (release = done));
+ });
+ });
+
+ const retried = handlers.get("retryApply")!();
+ await reconciling;
+ // 恢复仍卡在 reconcile:排空 microtask 后 getState 必须仍未 settle,而不是拿着半成品状态返回。
+ const concurrent = Promise.allSettled([handlers.get("getState")!()]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ release();
+
+ expect((await concurrent)[0]).toMatchObject({
+ status: "fulfilled",
+ value: { apply: { state: "applied" } },
+ });
+ await expect(retried).resolves.toMatchObject({ outcome: "applied" });
+ });
+
+ it("启动清理失败后可通过 retryApply 恢复,不会被失败的 ready 永久阻塞", async () => {
+ const { handlers, dao, applier } = createHarness({ schemaVersion: 2 } as unknown as NetworkRuleState);
+ applier.apply.mockRejectedValueOnce(new Error("temporary DNR failure"));
+
+ await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "unsupported_schema" });
+ dao.state = undefined;
+ const retried = (await handlers.get("retryApply")!()) as { outcome: string; state: NetworkRuleState };
+
+ expect(retried.outcome).toBe("applied");
+ expect(retried.state.revision).toBe(0);
+ expect(applier.apply).toHaveBeenCalledTimes(2);
+ expect(applier.apply).toHaveBeenNthCalledWith(1, []);
+ expect(applier.apply).toHaveBeenNthCalledWith(2, []);
+ });
+
+ it("两个共享 DAO 的 service instance 竞争同一 revision 时不会静默覆盖", async () => {
+ const first = createHarness();
+ const secondHandlers = new Map();
+ const secondGroup = {
+ on: vi.fn((name: string, handler: Handler) => secondHandlers.set(name, handler)),
+ } as unknown as Group;
+ const secondQueue = { publish: vi.fn() } as unknown as IMessageQueue;
+ const secondService = new NetworkRuleService(
+ secondGroup,
+ secondQueue,
+ first.dao,
+ compileNetworkRules,
+ first.applier
+ );
+ secondService.init();
+
+ const results = await Promise.allSettled([
+ first.handlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: 0,
+ condition: { requestDomains: ["first.example"] },
+ }),
+ secondHandlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: 0,
+ condition: { requestDomains: ["second.example"] },
+ }),
+ ]);
+
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.find((result) => result.status === "rejected")).toMatchObject({
+ status: "rejected",
+ reason: { code: "revision_conflict" },
+ });
+ expect(first.dao.state?.rules).toHaveLength(1);
+ });
+
+ it("并发 mutation 按保存与 reconcile 的完整顺序串行执行", async () => {
+ const order: string[] = [];
+ const { handlers, applier } = createHarness();
+ applier.apply.mockImplementation(async () => {
+ order.push("apply-start");
+ await Promise.resolve();
+ order.push("apply-end");
+ });
+ await handlers.get("getState")!();
+ const firstMutation = handlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: 0,
+ condition: { requestDomains: ["one.example"] },
+ });
+ const secondMutation = handlers.get("createRule")!({
+ ...cspRule,
+ baseRevision: 0,
+ condition: { requestDomains: ["two.example"] },
+ });
+ await expect(firstMutation).resolves.toMatchObject({ outcome: "applied" });
+ await expect(secondMutation).rejects.toMatchObject({ code: "revision_conflict" });
+ expect(order).toEqual(["apply-start", "apply-end", "apply-start", "apply-end"]);
+ });
+
+ it("经真实 DAO 与 chrome.storage 往返创建规则不会误报写入失败", async () => {
+ await chrome.storage.local.clear();
+ const handlers = new Map();
+ const group = {
+ on: vi.fn((name: string, handler: Handler) => handlers.set(name, handler)),
+ } as unknown as Group;
+ const queue = { publish: vi.fn() } as unknown as IMessageQueue;
+ const dao = new NetworkRuleStateDAO();
+ const applier = { apply: vi.fn(async () => {}) } as Mocked;
+ new NetworkRuleService(group, queue, dao, compileNetworkRules, applier).init();
+
+ const created = (await handlers.get("createRule")!({ ...cspRule, baseRevision: 0, name: "Example" })) as {
+ outcome: string;
+ state: NetworkRuleState;
+ };
+
+ expect(created.outcome).toBe("applied");
+ expect(created.state.rules).toHaveLength(1);
+ expect((await dao.getState())!.rules).toHaveLength(1);
+ });
+
+ it("初始化读取抛出非预期错误时报 storage_read_failed", async () => {
+ const { handlers } = createHarness(undefined, new Error("storage backend unavailable"));
+
+ await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "storage_read_failed" });
+ });
+
+ it("mutation 前置读取抛出非预期错误时报 storage_read_failed", async () => {
+ const { handlers, dao } = createHarness();
+ await handlers.get("getState")!();
+ vi.mocked(dao.getState).mockRejectedValueOnce(new Error("storage backend unavailable"));
+
+ await expect(handlers.get("createRule")!({ ...cspRule, baseRevision: 0 })).rejects.toMatchObject({
+ code: "storage_read_failed",
+ });
+ expect(dao.saveState).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/service/service_worker/network_rule.ts b/src/app/service/service_worker/network_rule.ts
new file mode 100644
index 000000000..282fc6c92
--- /dev/null
+++ b/src/app/service/service_worker/network_rule.ts
@@ -0,0 +1,593 @@
+import type { Group } from "@Packages/message/server";
+import type { IMessageQueue } from "@Packages/message/message_queue";
+import LoggerCore from "@App/app/logger/core";
+import { uuidv4 } from "@App/pkg/utils/uuid";
+import {
+ HEADER_OPERATIONS,
+ MAX_RULE_NAME_LENGTH,
+ NetworkRuleValidationError,
+ DEFAULT_NETWORK_RULE_STATE,
+ isDeniedRequestHeader,
+ isValidHeaderName,
+ validateNetworkRuleState,
+ type NetworkRule,
+ type NetworkRuleAction,
+ type NetworkRuleCondition,
+ type NetworkRuleHeaderEdit,
+ type NetworkRuleState,
+} from "@App/app/repo/network_rule";
+import type { NetworkRuleStateDAO } from "@App/app/repo/network_rule";
+import {
+ NETWORK_RULE_REQUEST_METHODS,
+ NETWORK_RULE_RESOURCE_TYPES,
+ RuleDomainError,
+ normalizeRuleDomain,
+ type NetworkRuleRequestMethod,
+ type NetworkRuleResourceType,
+} from "@App/pkg/utils/network_rule_condition";
+import { type NetworkRuleApplier, compileNetworkRules } from "./network_rule_compiler";
+
+export type { NetworkRuleApplier } from "./network_rule_compiler";
+
+export type NetworkRuleCreateInput = {
+ baseRevision: number;
+ name?: string;
+ enabled: boolean;
+ condition: NetworkRuleCondition;
+ action: NetworkRuleAction;
+};
+export type NetworkRuleUpdateInput = {
+ baseRevision: number;
+ id: string;
+ patch: Partial>;
+};
+export type NetworkRuleEnabledInput = { baseRevision: number; ids: string[]; enabled: boolean };
+export type NetworkRuleDeleteInput = { baseRevision: number; ids: string[] };
+export type NetworkRuleMasterEnabledInput = { baseRevision: number; enabled: boolean };
+export type NetworkRuleReorderInput = { baseRevision: number; order: string[] };
+
+export type NetworkRuleApplyStatus =
+ | { state: "applied"; revision: number; appliedAt: number }
+ | {
+ state: "error";
+ code: "dnr_apply_failed";
+ desiredRevision: number;
+ lastAppliedRevision?: number;
+ message: string;
+ };
+
+export type NetworkRuleSnapshot = { state: NetworkRuleState; apply: NetworkRuleApplyStatus };
+export type NetworkRuleMutationResult = NetworkRuleSnapshot & { outcome: "applied" | "apply-error" };
+
+export type NetworkRuleServiceErrorCode =
+ | "invalid_input"
+ | "not_found"
+ | "revision_conflict"
+ | "storage_read_failed"
+ | "storage_write_failed"
+ | "unsupported_schema";
+
+export type NetworkRuleServiceError = {
+ code: NetworkRuleServiceErrorCode;
+ path?: string;
+ messageKey?: string;
+ snapshot?: NetworkRuleSnapshot;
+};
+
+function serviceError(
+ code: NetworkRuleServiceErrorCode,
+ details: Omit = {}
+): NetworkRuleServiceError {
+ return { code, ...details };
+}
+
+function sameValue(left: unknown, right: unknown): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+/** 派生名同样要落在名称上限内,否则一条没填名字的长匹配式规则会在结构校验处被整条拒绝。 */
+function defaultRuleName(condition: NetworkRuleCondition): string {
+ const domains = condition.requestDomains;
+ const derived = domains?.length
+ ? `${domains[0]}${domains.length > 1 ? ` + ${domains.length - 1}` : ""}`
+ : condition.urlFilter!;
+ return Array.from(derived).slice(0, MAX_RULE_NAME_LENGTH).join("");
+}
+
+function normalizeRuleName(name: unknown, condition: NetworkRuleCondition, path: string): string {
+ if (name === undefined || (typeof name === "string" && name.trim() === "")) return defaultRuleName(condition);
+ if (typeof name !== "string" || Array.from(name.trim()).length > MAX_RULE_NAME_LENGTH) {
+ throw serviceError("invalid_input", { path, messageKey: "rule_name_invalid" });
+ }
+ return name.trim();
+}
+
+function normalizeDomainList(value: unknown, path: string): string[] {
+ if (!Array.isArray(value) || value.length === 0) {
+ throw serviceError("invalid_input", { path, messageKey: "domain_required" });
+ }
+ const domains: string[] = [];
+ for (const [index, domain] of value.entries()) {
+ try {
+ if (typeof domain !== "string") throw new RuleDomainError("domain_invalid");
+ const normalized = normalizeRuleDomain(domain);
+ if (!domains.includes(normalized)) domains.push(normalized);
+ } catch (error) {
+ const messageKey = error instanceof RuleDomainError ? error.messageKey : "domain_invalid";
+ throw serviceError("invalid_input", { path: `${path}[${index}]`, messageKey });
+ }
+ }
+ return domains;
+}
+
+function normalizeEnumList(
+ value: unknown,
+ allowed: readonly T[],
+ path: string,
+ messageKey: string
+): T[] {
+ if (!Array.isArray(value) || value.length === 0) {
+ throw serviceError("invalid_input", { path, messageKey });
+ }
+ const items: T[] = [];
+ for (const [index, item] of value.entries()) {
+ if (typeof item !== "string" || !allowed.includes(item as T)) {
+ throw serviceError("invalid_input", { path: `${path}[${index}]`, messageKey });
+ }
+ if (!items.includes(item as T)) items.push(item as T);
+ }
+ return items;
+}
+
+function normalizeCondition(condition: unknown, path: string): NetworkRuleCondition {
+ if (!condition || typeof condition !== "object") {
+ throw serviceError("invalid_input", { path, messageKey: "condition_invalid" });
+ }
+ const input = condition as Record;
+ const normalized: NetworkRuleCondition = {};
+
+ if (input.urlFilter !== undefined) {
+ const urlFilter = typeof input.urlFilter === "string" ? input.urlFilter.trim() : "";
+ if (!urlFilter || /\s/.test(urlFilter)) {
+ throw serviceError("invalid_input", { path: `${path}.urlFilter`, messageKey: "url_filter_invalid" });
+ }
+ normalized.urlFilter = urlFilter;
+ }
+ for (const key of [
+ "requestDomains",
+ "excludedRequestDomains",
+ "initiatorDomains",
+ "excludedInitiatorDomains",
+ ] as const) {
+ if (input[key] !== undefined) normalized[key] = normalizeDomainList(input[key], `${path}.${key}`);
+ }
+ if (normalized.urlFilter === undefined && normalized.requestDomains === undefined) {
+ throw serviceError("invalid_input", { path, messageKey: "condition_match_required" });
+ }
+ if (input.resourceTypes !== undefined) {
+ normalized.resourceTypes = normalizeEnumList(
+ input.resourceTypes,
+ NETWORK_RULE_RESOURCE_TYPES,
+ `${path}.resourceTypes`,
+ "resource_type_invalid"
+ );
+ }
+ if (input.requestMethods !== undefined) {
+ normalized.requestMethods = normalizeEnumList(
+ input.requestMethods,
+ NETWORK_RULE_REQUEST_METHODS,
+ `${path}.requestMethods`,
+ "request_method_invalid"
+ );
+ }
+ return normalized;
+}
+
+function normalizeHeaderName(value: unknown, path: string): string {
+ const header = typeof value === "string" ? value.trim().toLowerCase() : "";
+ if (!isValidHeaderName(header)) {
+ throw serviceError("invalid_input", { path, messageKey: "header_name_invalid" });
+ }
+ return header;
+}
+
+function normalizeHeaderEdits(value: unknown, path: string, denyRequestHeaders: boolean): NetworkRuleHeaderEdit[] {
+ if (!Array.isArray(value) || value.length === 0) {
+ throw serviceError("invalid_input", { path, messageKey: "header_count_invalid" });
+ }
+ return value.map((entry, index) => {
+ const itemPath = `${path}[${index}]`;
+ if (!entry || typeof entry !== "object") {
+ throw serviceError("invalid_input", { path: itemPath, messageKey: "header_name_invalid" });
+ }
+ const input = entry as Record;
+ const header = normalizeHeaderName(input.header, itemPath);
+ if (denyRequestHeaders && isDeniedRequestHeader(header)) {
+ throw serviceError("invalid_input", { path: itemPath, messageKey: "header_forbidden" });
+ }
+ const operation = input.operation;
+ if (typeof operation !== "string" || !HEADER_OPERATIONS.includes(operation as never)) {
+ throw serviceError("invalid_input", { path: `${itemPath}.operation`, messageKey: "header_operation_invalid" });
+ }
+ if (operation === "remove") {
+ if (input.value !== undefined) {
+ throw serviceError("invalid_input", { path: `${itemPath}.value`, messageKey: "header_value_invalid" });
+ }
+ return { header, operation: "remove" as const };
+ }
+ if (typeof input.value !== "string" || !input.value) {
+ throw serviceError("invalid_input", { path: `${itemPath}.value`, messageKey: "header_value_required" });
+ }
+ return { header, operation: operation as "set" | "append", value: input.value };
+ });
+}
+
+function normalizeAction(action: unknown, path: string): NetworkRuleAction {
+ if (!action || typeof action !== "object") {
+ throw serviceError("invalid_input", { path, messageKey: "action_invalid" });
+ }
+ const input = action as Record;
+ switch (input.type) {
+ case "removeResponseHeaders": {
+ if (!Array.isArray(input.headers) || input.headers.length === 0) {
+ throw serviceError("invalid_input", { path: `${path}.headers`, messageKey: "header_count_invalid" });
+ }
+ const headers: string[] = [];
+ for (const [index, header] of input.headers.entries()) {
+ const normalized = normalizeHeaderName(header, `${path}.headers[${index}]`);
+ if (!headers.includes(normalized)) headers.push(normalized);
+ }
+ return { type: "removeResponseHeaders", headers };
+ }
+ case "modifyRequestHeaders":
+ return { type: "modifyRequestHeaders", headers: normalizeHeaderEdits(input.headers, `${path}.headers`, true) };
+ case "modifyResponseHeaders":
+ return { type: "modifyResponseHeaders", headers: normalizeHeaderEdits(input.headers, `${path}.headers`, false) };
+ case "block":
+ return { type: "block" };
+ case "allow":
+ return { type: "allow" };
+ case "redirect": {
+ const url = typeof input.url === "string" ? input.url.trim() : "";
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw serviceError("invalid_input", { path: `${path}.url`, messageKey: "redirect_url_invalid" });
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw serviceError("invalid_input", { path: `${path}.url`, messageKey: "redirect_url_invalid" });
+ }
+ return { type: "redirect", url };
+ }
+ default:
+ throw serviceError("invalid_input", { path, messageKey: "action_invalid" });
+ }
+}
+
+/** 批量操作是全体或全不:任何一个 ID 不成立都在写入前整批拒绝。 */
+function resolveRuleIds(current: NetworkRuleState, value: unknown): Set {
+ if (!Array.isArray(value) || value.length === 0) {
+ throw serviceError("invalid_input", { path: "ids", messageKey: "rule_ids_invalid" });
+ }
+ const known = new Set(current.rules.map((rule) => rule.id));
+ const ids = new Set();
+ for (const [index, id] of value.entries()) {
+ if (typeof id !== "string") {
+ throw serviceError("invalid_input", { path: `ids[${index}]`, messageKey: "rule_ids_invalid" });
+ }
+ if (!known.has(id)) {
+ throw serviceError("not_found", { path: `ids[${index}]`, messageKey: "rule_not_found" });
+ }
+ ids.add(id);
+ }
+ return ids;
+}
+
+function validateBaseRevision(value: unknown): asserts value is number {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
+ throw serviceError("invalid_input", { path: "baseRevision", messageKey: "revision_invalid" });
+ }
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ if (typeof error === "string") return error;
+ return "DNR update failed";
+}
+
+export class NetworkRuleService {
+ private readonly logger = LoggerCore.getInstance().logger({ service: "networkRuleService" });
+ private confirmedState: NetworkRuleState | undefined;
+ private applyStatus: NetworkRuleApplyStatus | undefined;
+ private ready: Promise = Promise.resolve();
+ private mutationQueue: Promise = Promise.resolve();
+ private initializationError: NetworkRuleServiceError | undefined;
+
+ constructor(
+ private readonly group: Group,
+ private readonly messageQueue: IMessageQueue,
+ private readonly stateDAO: NetworkRuleStateDAO,
+ private readonly compiler: typeof compileNetworkRules = compileNetworkRules,
+ private readonly applier: NetworkRuleApplier
+ ) {}
+
+ init() {
+ this.group.on("getState", () => this.getState());
+ this.group.on("createRule", (input: NetworkRuleCreateInput) => this.createRule(input));
+ this.group.on("updateRule", (input: NetworkRuleUpdateInput) => this.updateRule(input));
+ this.group.on("deleteRules", (input: NetworkRuleDeleteInput) => this.deleteRules(input));
+ this.group.on("setRulesEnabled", (input: NetworkRuleEnabledInput) => this.setRulesEnabled(input));
+ this.group.on("setMasterEnabled", (input: NetworkRuleMasterEnabledInput) => this.setMasterEnabled(input));
+ this.group.on("reorderRules", (input: NetworkRuleReorderInput) => this.reorderRules(input));
+ this.group.on("retryApply", () => this.retryApply());
+ this.ready = this.initialize();
+ }
+
+ private async initialize(): Promise {
+ let state: NetworkRuleState;
+ try {
+ state = (await this.stateDAO.getState()) ?? { ...DEFAULT_NETWORK_RULE_STATE, rules: [], order: [] };
+ validateNetworkRuleState(state);
+ } catch (error) {
+ if (error instanceof NetworkRuleValidationError) {
+ try {
+ await this.applier.apply([]);
+ } catch {
+ // 清理失败时保留原数据,并让 retryApply 重新执行清理。
+ }
+ this.initializationError = serviceError("unsupported_schema", {
+ path: error.path,
+ messageKey: error.messageKey,
+ });
+ } else {
+ // getState 是纯读取路径,非预期异常同样属于读失败。
+ this.initializationError = serviceError("storage_read_failed");
+ }
+ return;
+ }
+ this.confirmedState = state;
+ await this.reconcile(state);
+ // 放在最后清空:恢复期间 confirmedState / applyStatus 尚未双双就绪,此时并发的 getState
+ // 应当继续看到上一次的失败原因,而不是「无错误也无快照」的中间态。
+ this.initializationError = undefined;
+ }
+
+ private async waitUntilReady(): Promise {
+ await this.ready;
+ if (this.initializationError) throw this.initializationError;
+ }
+
+ private snapshot(): NetworkRuleSnapshot {
+ if (!this.confirmedState || !this.applyStatus) throw serviceError("storage_write_failed");
+ return { state: this.confirmedState, apply: this.applyStatus };
+ }
+
+ private async enqueue(operation: () => Promise): Promise {
+ const next = this.mutationQueue.then(() => this.stateDAO.runExclusive(operation));
+ this.mutationQueue = next.then(
+ () => undefined,
+ () => undefined
+ );
+ return next;
+ }
+
+ private async reconcile(state: NetworkRuleState): Promise {
+ try {
+ await this.applier.apply(this.compiler(state));
+ const applied: NetworkRuleApplyStatus = { state: "applied", revision: state.revision, appliedAt: Date.now() };
+ this.applyStatus = applied;
+ return applied;
+ } catch (error) {
+ const previous = this.applyStatus;
+ const failed: NetworkRuleApplyStatus = {
+ state: "error",
+ code: "dnr_apply_failed",
+ desiredRevision: state.revision,
+ lastAppliedRevision: previous?.state === "applied" ? previous.revision : previous?.lastAppliedRevision,
+ message: errorMessage(error),
+ };
+ this.applyStatus = failed;
+ return failed;
+ }
+ }
+
+ private async getState(): Promise {
+ await this.waitUntilReady();
+ return this.snapshot();
+ }
+
+ private async currentForMutation(baseRevision: unknown): Promise {
+ await this.waitUntilReady();
+ validateBaseRevision(baseRevision);
+ let persisted: NetworkRuleState | undefined;
+ try {
+ persisted = await this.stateDAO.getState();
+ if (persisted) validateNetworkRuleState(persisted);
+ } catch (error) {
+ if (error instanceof NetworkRuleValidationError) {
+ throw serviceError("unsupported_schema", { path: error.path, messageKey: error.messageKey });
+ }
+ throw serviceError("storage_read_failed");
+ }
+ if (persisted) this.confirmedState = persisted;
+ const current = this.confirmedState!;
+ if (baseRevision !== current.revision) {
+ throw serviceError("revision_conflict", { snapshot: this.snapshot() });
+ }
+ return current;
+ }
+
+ private async saveAndApply(state: NetworkRuleState): Promise {
+ try {
+ await this.stateDAO.saveState(state);
+ } catch (error) {
+ if (error instanceof NetworkRuleValidationError) {
+ throw serviceError("storage_write_failed", { path: error.path, messageKey: error.messageKey });
+ }
+ throw serviceError("storage_write_failed");
+ }
+ this.confirmedState = state;
+ const apply = await this.reconcile(state);
+ const snapshot = this.snapshot();
+ this.publishStateChanged(snapshot);
+ return { ...snapshot, outcome: apply.state === "applied" ? "applied" : "apply-error" };
+ }
+
+ private publishStateChanged(snapshot: NetworkRuleSnapshot): void {
+ try {
+ this.messageQueue.publish("networkRule/stateChanged", snapshot);
+ } catch (error) {
+ this.logger.warn("发布网络规则状态失败", { error: String(error) });
+ }
+ }
+
+ private unchanged(): NetworkRuleMutationResult {
+ return { ...this.snapshot(), outcome: this.applyStatus?.state === "applied" ? "applied" : "apply-error" };
+ }
+
+ private saveValidated(state: NetworkRuleState): Promise {
+ try {
+ validateNetworkRuleState(state);
+ } catch (error) {
+ if (error instanceof NetworkRuleValidationError) {
+ throw serviceError("invalid_input", { path: error.path, messageKey: error.messageKey });
+ }
+ throw error;
+ }
+ return this.saveAndApply(state);
+ }
+
+ private async createRule(input: NetworkRuleCreateInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ if (typeof input?.enabled !== "boolean") {
+ throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" });
+ }
+ const condition = normalizeCondition(input.condition, "condition");
+ const action = normalizeAction(input.action, "action");
+ const name = normalizeRuleName(input.name, condition, "name");
+ const now = Date.now();
+ const rule: NetworkRule = {
+ id: uuidv4(),
+ name,
+ enabled: input.enabled,
+ condition,
+ action,
+ createdAt: now,
+ updatedAt: now,
+ };
+ return this.saveValidated({
+ ...current,
+ revision: current.revision + 1,
+ rules: [...current.rules, rule],
+ order: [...current.order, rule.id],
+ });
+ });
+ }
+
+ private async updateRule(input: NetworkRuleUpdateInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ const index = current.rules.findIndex((rule) => rule.id === input?.id);
+ if (index < 0) throw serviceError("not_found", { path: "id", messageKey: "rule_not_found" });
+ const patch = input.patch;
+ if (!patch || Object.keys(patch).length === 0) {
+ throw serviceError("invalid_input", { path: "patch", messageKey: "patch_empty" });
+ }
+ for (const key of Object.keys(patch)) {
+ if (key !== "name" && key !== "condition" && key !== "action") {
+ throw serviceError("invalid_input", { path: `patch.${key}`, messageKey: "patch_field_invalid" });
+ }
+ }
+ const oldRule = current.rules[index];
+ const condition =
+ patch.condition === undefined ? oldRule.condition : normalizeCondition(patch.condition, "patch.condition");
+ const action = patch.action === undefined ? oldRule.action : normalizeAction(patch.action, "patch.action");
+ const name = normalizeRuleName(patch.name === undefined ? oldRule.name : patch.name, condition, "patch.name");
+ if (name === oldRule.name && sameValue(condition, oldRule.condition) && sameValue(action, oldRule.action)) {
+ return this.unchanged();
+ }
+ const rules = [...current.rules];
+ rules[index] = { ...oldRule, name, condition, action, updatedAt: Date.now() };
+ return this.saveValidated({ ...current, revision: current.revision + 1, rules });
+ });
+ }
+
+ private async deleteRules(input: NetworkRuleDeleteInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ const ids = resolveRuleIds(current, input?.ids);
+ // 顺序数组与规则集在同一次写入里裁剪,删完不会留下指向已删规则的顺序项。
+ return this.saveAndApply({
+ ...current,
+ revision: current.revision + 1,
+ rules: current.rules.filter((rule) => !ids.has(rule.id)),
+ order: current.order.filter((id) => !ids.has(id)),
+ });
+ });
+ }
+
+ private async setRulesEnabled(input: NetworkRuleEnabledInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ const ids = resolveRuleIds(current, input?.ids);
+ if (typeof input.enabled !== "boolean") {
+ throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" });
+ }
+ const changing = current.rules.filter((rule) => ids.has(rule.id) && rule.enabled !== input.enabled);
+ if (changing.length === 0) return this.unchanged();
+ const changed = new Set(changing.map((rule) => rule.id));
+ const updatedAt = Date.now();
+ const rules = current.rules.map((rule) =>
+ changed.has(rule.id) ? { ...rule, enabled: input.enabled, updatedAt } : rule
+ );
+ return this.saveAndApply({ ...current, revision: current.revision + 1, rules });
+ });
+ }
+
+ private async setMasterEnabled(input: NetworkRuleMasterEnabledInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ if (typeof input.enabled !== "boolean") {
+ throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" });
+ }
+ if (current.masterEnabled === input.enabled) return this.unchanged();
+ return this.saveAndApply({ ...current, masterEnabled: input.enabled, revision: current.revision + 1 });
+ });
+ }
+
+ private async reorderRules(input: NetworkRuleReorderInput): Promise {
+ return this.enqueue(async () => {
+ const current = await this.currentForMutation(input?.baseRevision);
+ const order = input?.order;
+ if (!Array.isArray(order) || order.length !== current.order.length) {
+ throw serviceError("invalid_input", { path: "order", messageKey: "order_invalid" });
+ }
+ const known = new Set(current.order);
+ const seen = new Set();
+ for (const [index, id] of order.entries()) {
+ if (typeof id !== "string" || !known.has(id) || seen.has(id)) {
+ throw serviceError("invalid_input", { path: `order[${index}]`, messageKey: "order_invalid" });
+ }
+ seen.add(id);
+ }
+ if (sameValue(order, current.order)) return this.unchanged();
+ return this.saveAndApply({ ...current, revision: current.revision + 1, order: [...order] });
+ });
+ }
+
+ private async retryApply(): Promise {
+ return this.enqueue(async () => {
+ const recoveringInitialization = this.initializationError !== undefined;
+ // 重新初始化必须换掉 ready,否则并发的 getState 会越过一个早已 settle 的旧 ready。
+ if (recoveringInitialization) this.ready = this.initialize();
+ await this.waitUntilReady();
+ const state = this.confirmedState!;
+ const apply = recoveringInitialization ? this.applyStatus! : await this.reconcile(state);
+ const snapshot = this.snapshot();
+ this.publishStateChanged(snapshot);
+ return { ...snapshot, outcome: apply.state === "applied" ? "applied" : "apply-error" };
+ });
+ }
+}
diff --git a/src/app/service/service_worker/network_rule_compiler.test.ts b/src/app/service/service_worker/network_rule_compiler.test.ts
new file mode 100644
index 000000000..504a3c5e4
--- /dev/null
+++ b/src/app/service/service_worker/network_rule_compiler.test.ts
@@ -0,0 +1,263 @@
+import { describe, expect, it, vi } from "vitest";
+import type { NetworkRule, NetworkRuleState } from "@App/app/repo/network_rule";
+import { compileNetworkRules, DeclarativeNetRequestUserRuleApplier } from "./network_rule_compiler";
+import {
+ INSTALL_GUARD_RULE_ID_MAX,
+ INSTALL_REDIRECT_RULE_ID_MAX,
+ INSTALL_GUARD_RULE_ID_MIN,
+ INTERNAL_DNR_GUARD_PRIORITY,
+ INTERNAL_DNR_PRIORITY,
+ MAX_USER_RULES,
+ USER_RULE_ID_MIN,
+ buildInstallGuardRules,
+ isUserRuleId,
+} from "./dnr_rule_ids";
+import { NETWORK_RULE_RESOURCE_TYPES } from "@App/pkg/utils/network_rule_condition";
+
+type DnrMock = typeof chrome.declarativeNetRequest & {
+ resetMock(): void;
+ dynamicUpdateError?: string;
+ getDynamicRules(): Promise;
+};
+
+const dnr = chrome.declarativeNetRequest as DnrMock;
+
+function rule(id: string, action: NetworkRule["action"], enabled = true): NetworkRule {
+ return {
+ id,
+ name: id,
+ enabled,
+ condition: { requestDomains: [`${id}.example.com`] },
+ action,
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+function state(rules: NetworkRule[], overrides: Partial = {}): NetworkRuleState {
+ return {
+ schemaVersion: 1,
+ revision: 1,
+ masterEnabled: true,
+ rules,
+ order: rules.map((item) => item.id),
+ ...overrides,
+ };
+}
+
+// chrome.declarativeNetRequest 的匹配裁决:先比 priority,只有 priority 相同才按动作序裁决。
+// 见 https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest#matching-algorithm
+const ACTION_PRECEDENCE = ["allowAllRequests", "allow", "block", "upgradeScheme", "redirect"];
+function arbitrate(rules: chrome.declarativeNetRequest.Rule[]): chrome.declarativeNetRequest.Rule {
+ return [...rules].sort(
+ (a, b) =>
+ (b.priority ?? 1) - (a.priority ?? 1) ||
+ ACTION_PRECEDENCE.indexOf(a.action.type) - ACTION_PRECEDENCE.indexOf(b.action.type)
+ )[0];
+}
+
+// script.ts 注册的 .user.js 安装重定向条件(请求阶段部分)。
+const installCondition: chrome.declarativeNetRequest.RuleCondition = {
+ regexFilter: "^(https?://.+?\\.user\\.js)",
+ resourceTypes: ["main_frame" as chrome.declarativeNetRequest.ResourceType],
+ requestMethods: ["get" as chrome.declarativeNetRequest.RequestMethod],
+ excludedTabIds: [-1],
+};
+
+// Chrome ≥128 上 script.ts 给安装重定向加了 Content-Type 条件,该规则因此只能在响应头阶段裁决。
+const installRedirect: chrome.declarativeNetRequest.Rule = {
+ id: 1000,
+ priority: INTERNAL_DNR_PRIORITY,
+ action: { type: "redirect" as chrome.declarativeNetRequest.RuleActionType, redirect: { url: "install.html" } },
+ condition: {
+ ...installCondition,
+ responseHeaders: [{ header: "Content-Type", values: ["text/javascript*"] }],
+ } as chrome.declarativeNetRequest.RuleCondition,
+};
+
+/** 带 responseHeaders 条件的规则不参与请求阶段裁决——用户的 block 正是在这一阶段短路请求的。 */
+function requestPhase(rules: chrome.declarativeNetRequest.Rule[]): chrome.declarativeNetRequest.Rule[] {
+ return rules.filter((item) => !("responseHeaders" in item.condition));
+}
+
+describe("网络规则 DNR 编译与对账", () => {
+ it("一次应用把两条不同动作的规则编译成保留段内的两条 DNR 规则,回收段内陈旧 ID 并保留段外规则", async () => {
+ dnr.resetMock();
+ await chrome.declarativeNetRequest.updateDynamicRules({
+ addRules: [
+ { id: 2, priority: INTERNAL_DNR_PRIORITY, action: { type: "allow" }, condition: {} },
+ { id: USER_RULE_ID_MIN + 7, priority: 1, action: { type: "block" }, condition: {} },
+ ] as chrome.declarativeNetRequest.Rule[],
+ });
+
+ const compiled = compileNetworkRules(
+ state([
+ rule("csp", { type: "removeResponseHeaders", headers: ["content-security-policy"] }),
+ rule("blocked", { type: "block" }),
+ ])
+ );
+ await new DeclarativeNetRequestUserRuleApplier().apply(compiled);
+
+ const applied = await dnr.getDynamicRules();
+ const userRules = applied.filter((item) => isUserRuleId(item.id));
+ expect(userRules.map((item) => item.id)).toEqual([USER_RULE_ID_MIN, USER_RULE_ID_MIN + 1]);
+ expect(userRules.map((item) => item.priority)).toEqual([2, 1]);
+ expect(userRules.map((item) => item.action.type)).toEqual(["modifyHeaders", "block"]);
+ expect(userRules[0].action.responseHeaders).toEqual([{ header: "content-security-policy", operation: "remove" }]);
+ expect(applied.some((item) => item.id === USER_RULE_ID_MIN + 7)).toBe(false);
+ expect(applied.find((item) => item.id === 2)).toEqual({
+ id: 2,
+ priority: INTERNAL_DNR_PRIORITY,
+ action: { type: "allow" },
+ condition: {},
+ });
+ });
+
+ it("用户 block 规则排在列表首位时,.user.js 请求先被内部 allow 放行,再由响应阶段的安装重定向胜出", () => {
+ const compiled = compileNetworkRules(
+ state([
+ rule("blocked", { type: "block" }),
+ rule("csp", { type: "removeResponseHeaders", headers: ["content-security-policy"] }),
+ ])
+ );
+ const [guard] = buildInstallGuardRules([installCondition]);
+ const matching = [...compiled, guard, installRedirect];
+
+ expect(compiled[0].action.type).toBe("block");
+ // 请求阶段:只比 allow 与 block,守卫优先级更高,请求没有被短路,得以活到响应阶段。
+ expect(arbitrate(requestPhase(matching)).id).toBe(guard.id);
+ // 响应头阶段:重定向优先级严格高于守卫,安装页仍然打开。
+ expect(arbitrate(matching).id).toBe(installRedirect.id);
+ // 用户段 < 守卫段 < 内部段,三者两两不同优先级,因此不存在同优先级动作序裁决的机会。
+ expect(MAX_USER_RULES).toBeLessThan(INTERNAL_DNR_GUARD_PRIORITY);
+ expect(INTERNAL_DNR_GUARD_PRIORITY).toBeLessThan(INTERNAL_DNR_PRIORITY);
+ });
+
+ it("请求阶段守卫按安装重定向的原条件生成,落在保留段内且不带响应头条件", () => {
+ const second: chrome.declarativeNetRequest.RuleCondition = {
+ regexFilter: "^([^?#]+?\\.cat\\.md)",
+ resourceTypes: ["main_frame" as chrome.declarativeNetRequest.ResourceType],
+ excludedTabIds: [-1],
+ };
+ const guards = buildInstallGuardRules([installCondition, second]);
+
+ expect(guards.map((item) => item.id)).toEqual([INSTALL_GUARD_RULE_ID_MIN, INSTALL_GUARD_RULE_ID_MIN + 1]);
+ expect(guards.some((item) => isUserRuleId(item.id))).toBe(false);
+ // 守卫段既不与安装重定向段重叠,也不与用户段重叠。
+ expect(INSTALL_REDIRECT_RULE_ID_MAX).toBeLessThan(INSTALL_GUARD_RULE_ID_MIN);
+ expect(INSTALL_GUARD_RULE_ID_MAX).toBeLessThan(USER_RULE_ID_MIN);
+ expect(guards.map((item) => item.action)).toEqual([{ type: "allow" }, { type: "allow" }]);
+ expect(guards.map((item) => item.priority)).toEqual([INTERNAL_DNR_GUARD_PRIORITY, INTERNAL_DNR_GUARD_PRIORITY]);
+ // 条件与安装重定向逐字一致,守卫因此不会豁免比重定向更宽的请求。
+ expect(guards.map((item) => item.condition)).toEqual([installCondition, second]);
+ // 加上 responseHeaders 就会掉进响应阶段,守卫必须留在请求阶段。
+ expect(guards.some((item) => "responseHeaders" in item.condition)).toBe(false);
+ });
+
+ it("条件未选资源类型时编译出的规则覆盖包含 main_frame 在内的全部资源类型,选了则原样保留", () => {
+ const [byDefault, explicit] = compileNetworkRules(
+ state([
+ rule("csp", { type: "removeResponseHeaders", headers: ["content-security-policy"] }),
+ {
+ ...rule("picked", { type: "block" }),
+ condition: { requestDomains: ["picked.example.com"], resourceTypes: ["script"] },
+ },
+ ])
+ );
+ expect(byDefault.condition.resourceTypes).toEqual([...NETWORK_RULE_RESOURCE_TYPES]);
+ expect(byDefault.condition.resourceTypes).toContain("main_frame");
+ expect(explicit.condition.resourceTypes).toEqual(["script"]);
+ });
+
+ it("按动作可辨识联合翻译成对应的 DNR action", () => {
+ const compiled = compileNetworkRules(
+ state([
+ rule("req", {
+ type: "modifyRequestHeaders",
+ headers: [{ header: "x-flag", operation: "set", value: "1" }],
+ }),
+ rule("res", { type: "modifyResponseHeaders", headers: [{ header: "x-frame-options", operation: "remove" }] }),
+ rule("go", { type: "redirect", url: "https://example.com/target" }),
+ rule("pass", { type: "allow" }),
+ ])
+ );
+ expect(compiled.map((item) => item.action)).toEqual([
+ { type: "modifyHeaders", requestHeaders: [{ header: "x-flag", operation: "set", value: "1" }] },
+ { type: "modifyHeaders", responseHeaders: [{ header: "x-frame-options", operation: "remove" }] },
+ { type: "redirect", redirect: { url: "https://example.com/target" } },
+ { type: "allow" },
+ ]);
+ });
+
+ it("条件按受控子集原样翻译", () => {
+ const [compiled] = compileNetworkRules(
+ state([
+ {
+ ...rule("cond", { type: "block" }),
+ condition: {
+ urlFilter: "*/api/*",
+ requestDomains: ["example.com"],
+ excludedRequestDomains: ["cdn.example.com"],
+ initiatorDomains: ["app.example.com"],
+ excludedInitiatorDomains: ["intranet.example.com"],
+ resourceTypes: ["xmlhttprequest"],
+ requestMethods: ["post"],
+ },
+ },
+ ])
+ );
+ expect(compiled.condition).toEqual({
+ urlFilter: "*/api/*",
+ requestDomains: ["example.com"],
+ excludedRequestDomains: ["cdn.example.com"],
+ initiatorDomains: ["app.example.com"],
+ excludedInitiatorDomains: ["intranet.example.com"],
+ resourceTypes: ["xmlhttprequest"],
+ requestMethods: ["post"],
+ });
+ });
+
+ it("停用的规则不占用 ID,但保留其余规则的相对优先级", () => {
+ const compiled = compileNetworkRules(
+ state([rule("first", { type: "block" }), rule("off", { type: "block" }, false), rule("last", { type: "allow" })])
+ );
+ expect(compiled.map((item) => [item.id, item.priority])).toEqual([
+ [USER_RULE_ID_MIN, 3],
+ [USER_RULE_ID_MIN + 1, 1],
+ ]);
+ });
+
+ it("总开关关闭或没有启用规则时编译为空规则", () => {
+ expect(compileNetworkRules(state([rule("one", { type: "block" })], { masterEnabled: false }))).toEqual([]);
+ expect(compileNetworkRules(state([rule("off", { type: "block" }, false)]))).toEqual([]);
+ });
+
+ it("对账读取动态规则失败时不改动任何规则", async () => {
+ dnr.resetMock();
+ await chrome.declarativeNetRequest.updateDynamicRules({
+ addRules: [
+ { id: 2, priority: 1, action: { type: "allow" }, condition: {} },
+ ] as chrome.declarativeNetRequest.Rule[],
+ });
+ const getDynamicRules = vi
+ .spyOn(chrome.declarativeNetRequest, "getDynamicRules")
+ .mockRejectedValueOnce(new Error("read failed"));
+
+ await expect(new DeclarativeNetRequestUserRuleApplier().apply([])).rejects.toThrow("read failed");
+ getDynamicRules.mockRestore();
+ expect((await dnr.getDynamicRules()).map((item) => item.id)).toEqual([2]);
+ });
+
+ it("DNR 更新失败时保留现有规则", async () => {
+ dnr.resetMock();
+ await chrome.declarativeNetRequest.updateDynamicRules({
+ addRules: [
+ { id: 2, priority: 1, action: { type: "allow" }, condition: {} },
+ ] as chrome.declarativeNetRequest.Rule[],
+ });
+ const before = await dnr.getDynamicRules();
+ dnr.dynamicUpdateError = "permission denied";
+ await expect(new DeclarativeNetRequestUserRuleApplier().apply([])).rejects.toThrow("permission denied");
+ expect(await dnr.getDynamicRules()).toEqual(before);
+ });
+});
diff --git a/src/app/service/service_worker/network_rule_compiler.ts b/src/app/service/service_worker/network_rule_compiler.ts
new file mode 100644
index 000000000..a2d38184e
--- /dev/null
+++ b/src/app/service/service_worker/network_rule_compiler.ts
@@ -0,0 +1,155 @@
+import type {
+ NetworkRule,
+ NetworkRuleAction,
+ NetworkRuleCondition,
+ NetworkRuleState,
+} from "@App/app/repo/network_rule";
+import { NETWORK_RULE_RESOURCE_TYPES } from "@App/pkg/utils/network_rule_condition";
+import { USER_RULE_ID_MIN, isUserRuleId } from "./dnr_rule_ids";
+
+function compileCondition(condition: NetworkRuleCondition): chrome.declarativeNetRequest.RuleCondition {
+ const compiled: chrome.declarativeNetRequest.RuleCondition = {};
+ if (condition.urlFilter !== undefined) compiled.urlFilter = condition.urlFilter;
+ if (condition.requestDomains) compiled.requestDomains = [...condition.requestDomains];
+ if (condition.excludedRequestDomains) compiled.excludedRequestDomains = [...condition.excludedRequestDomains];
+ if (condition.initiatorDomains) compiled.initiatorDomains = [...condition.initiatorDomains];
+ if (condition.excludedInitiatorDomains) compiled.excludedInitiatorDomains = [...condition.excludedInitiatorDomains];
+ // DNR 自身的默认是「除 main_frame 外的全部资源类型」,会让「移除 CSP」「屏蔽请求」这类面向页面
+ // 本身的规则对导航无效(运行时验证已观察到)。资源类型又位于默认折叠的高级区,因此未显式选择时
+ // 在这里把受控集合列全:用户选了什么就编译成什么,没选则连主文档一起覆盖。
+ compiled.resourceTypes = [
+ ...(condition.resourceTypes ?? NETWORK_RULE_RESOURCE_TYPES),
+ ] as chrome.declarativeNetRequest.ResourceType[];
+ if (condition.requestMethods)
+ compiled.requestMethods = [...condition.requestMethods] as chrome.declarativeNetRequest.RequestMethod[];
+ return compiled;
+}
+
+function compileAction(action: NetworkRuleAction): chrome.declarativeNetRequest.RuleAction {
+ switch (action.type) {
+ case "removeResponseHeaders":
+ return {
+ type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
+ responseHeaders: action.headers.map((header) => ({
+ header,
+ operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
+ })),
+ };
+ case "modifyRequestHeaders":
+ return {
+ type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
+ requestHeaders: action.headers.map((edit) => ({
+ header: edit.header,
+ operation: edit.operation as chrome.declarativeNetRequest.HeaderOperation,
+ ...(edit.value === undefined ? {} : { value: edit.value }),
+ })),
+ };
+ case "modifyResponseHeaders":
+ return {
+ type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
+ responseHeaders: action.headers.map((edit) => ({
+ header: edit.header,
+ operation: edit.operation as chrome.declarativeNetRequest.HeaderOperation,
+ ...(edit.value === undefined ? {} : { value: edit.value }),
+ })),
+ };
+ case "block":
+ return { type: "block" as chrome.declarativeNetRequest.RuleActionType };
+ case "allow":
+ return { type: "allow" as chrome.declarativeNetRequest.RuleActionType };
+ case "redirect":
+ return {
+ type: "redirect" as chrome.declarativeNetRequest.RuleActionType,
+ redirect: { url: action.url },
+ };
+ }
+}
+
+/**
+ * 一条用户规则编译成一条 DNR 规则:ID 按位次从保留段顺序分配,priority 由位次倒序映射,
+ * 因此列表第一行拿到最高优先级,且用户段整体严格低于 INTERNAL_DNR_PRIORITY。
+ */
+export function compileNetworkRules(state: NetworkRuleState): chrome.declarativeNetRequest.Rule[] {
+ if (!state.masterEnabled) return [];
+ const byId = new Map(state.rules.map((rule) => [rule.id, rule]));
+ const compiled: chrome.declarativeNetRequest.Rule[] = [];
+ state.order.forEach((ruleId, index) => {
+ const rule = byId.get(ruleId);
+ if (!rule?.enabled) return;
+ compiled.push({
+ id: USER_RULE_ID_MIN + compiled.length,
+ priority: state.order.length - index,
+ action: compileAction(rule.action),
+ condition: compileCondition(rule.condition),
+ });
+ });
+ return compiled;
+}
+
+export interface NetworkRuleApplier {
+ apply(rules: chrome.declarativeNetRequest.Rule[]): Promise;
+}
+
+type DynamicRuleUpdateOptions = {
+ removeRuleIds: number[];
+ addRules: chrome.declarativeNetRequest.Rule[];
+};
+
+function toApplyError(error: unknown): Error {
+ if (error instanceof Error) return error;
+ if (typeof error === "string") return new Error(error);
+ return new Error("DNR update failed");
+}
+
+export class DeclarativeNetRequestUserRuleApplier implements NetworkRuleApplier {
+ /**
+ * 与 getDynamicRules() 做全量对账:保留段内的 ID 一律先回收(含历史版本遗留的位次),
+ * 段外的动态规则不出现在 removeRuleIds 里,因此其它功能注册的规则不会被误删。
+ */
+ async apply(rules: chrome.declarativeNetRequest.Rule[]): Promise {
+ let removeRuleIds: number[];
+ try {
+ const existing = await chrome.declarativeNetRequest.getDynamicRules();
+ removeRuleIds = existing.map((rule) => rule.id).filter(isUserRuleId);
+ } catch (error) {
+ throw toApplyError(error);
+ }
+ return this.updateDynamicRules({ removeRuleIds, addRules: rules });
+ }
+
+ private updateDynamicRules(options: DynamicRuleUpdateOptions): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const done = (error?: unknown) => {
+ if (settled) return;
+ settled = true;
+ if (error) reject(toApplyError(error));
+ else resolve();
+ };
+
+ try {
+ const updateDynamicRules = chrome.declarativeNetRequest.updateDynamicRules as unknown as (
+ this: typeof chrome.declarativeNetRequest,
+ options: DynamicRuleUpdateOptions,
+ callback: () => void
+ ) => Promise | void;
+ const result = updateDynamicRules.call(chrome.declarativeNetRequest, options, () => {
+ const lastError = chrome.runtime.lastError;
+ if (lastError) {
+ done(lastError.message);
+ return;
+ }
+ done();
+ });
+ if (result && typeof result.then === "function") {
+ void result.then(
+ () => done(),
+ (error: unknown) => done(error)
+ );
+ }
+ } catch (error) {
+ done(error);
+ }
+ });
+ }
+}
diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts
index 23e4cfe67..89c90a162 100644
--- a/src/app/service/service_worker/script.ts
+++ b/src/app/service/service_worker/script.ts
@@ -58,6 +58,7 @@ import { EnableAgent } from "@App/app/const";
import { TrashScriptDAO } from "@App/app/repo/trash_script";
import type { TrashScript } from "@App/app/repo/trash_script";
import { SubscribeDAO } from "@App/app/repo/subscribe";
+import { INSTALL_REDIRECT_RULE_ID_MIN, INTERNAL_DNR_PRIORITY, buildInstallGuardRules } from "./dnr_rule_ids";
export type TCheckScriptUpdateOption = Partial<
{ checkType: "user"; noUpdateCheck?: number } | ({ checkType: "system" } & Record)
@@ -152,7 +153,7 @@ export class ScriptService {
addRules: [
{
id: 2,
- priority: 1,
+ priority: INTERNAL_DNR_PRIORITY,
action: {
type: "allow" as chrome.declarativeNetRequest.RuleActionType,
},
@@ -300,10 +301,14 @@ export class ScriptService {
: []),
];
const installPageURL = chrome.runtime.getURL("src/install.html");
- const rules = conditions.map((condition, idx) => {
+ for (const condition of conditions) {
Object.assign(condition, {
excludedTabIds: [chrome.tabs.TAB_ID_NONE],
});
+ }
+ // 必须在附加 responseHeaders 之前取条件:守卫要留在请求阶段,才能在用户的 block 短路请求之前放行。
+ const guardRules = buildInstallGuardRules(conditions);
+ const rules = conditions.map((condition, idx) => {
if (addResponseHeaders) {
Object.assign(condition, {
responseHeaders: [
@@ -323,8 +328,8 @@ export class ScriptService {
});
}
return {
- id: 1000 + idx,
- priority: 1,
+ id: INSTALL_REDIRECT_RULE_ID_MIN + idx,
+ priority: INTERNAL_DNR_PRIORITY,
action: {
type: "redirect" as chrome.declarativeNetRequest.RuleActionType,
redirect: {
@@ -352,8 +357,8 @@ export class ScriptService {
);
chrome.declarativeNetRequest.updateSessionRules(
{
- removeRuleIds: [...rules.map((rule) => rule.id)],
- addRules: rules,
+ removeRuleIds: [...guardRules.map((rule) => rule.id), ...rules.map((rule) => rule.id)],
+ addRules: [...guardRules, ...rules],
},
() => {
if (chrome.runtime.lastError) {
diff --git a/src/locales/de-DE/tools.json b/src/locales/de-DE/tools.json
index 8568963be..0f7673ce3 100644
--- a/src/locales/de-DE/tools.json
+++ b/src/locales/de-DE/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Lokale Sicherung",
"cloud_backup": "Cloud-Sicherung",
"auto_backup": "Automatische Sicherung",
- "data_migration": "Datenmigration"
+ "data_migration": "Datenmigration",
+ "network_rules_title": "Netzwerkregeln",
+ "network_rules_back": "Zurück zu Werkzeuge",
+ "network_rules_master_switch": "Hauptschalter",
+ "network_rules_search_placeholder": "Regelnamen oder Domains suchen",
+ "network_rules_filter_action": "Aktion",
+ "network_rules_filter_status": "Status",
+ "network_rules_filter_all_actions": "Alle Aktionen",
+ "network_rules_filter_all_statuses": "Alle Status",
+ "network_rules_status_enabled": "Aktiviert",
+ "network_rules_status_disabled": "Deaktiviert",
+ "network_rules_test_match": "Treffer testen",
+ "network_rules_new_rule": "Neue Regel",
+ "network_rule_action_remove_response_headers": "Antwort-Header entfernen",
+ "network_rule_action_modify_request_headers": "Anfrage-Header ändern",
+ "network_rule_action_modify_response_headers": "Antwort-Header ändern",
+ "network_rule_action_block": "Blockieren",
+ "network_rule_action_redirect": "Umleiten",
+ "network_rule_action_allow": "Zulassen",
+ "network_rules_column_name": "Name",
+ "network_rules_column_action": "Aktion",
+ "network_rules_column_scope": "Geltungsbereich",
+ "network_rules_column_order": "Reihenfolge",
+ "network_rules_select_all": "Regeln auf dieser Seite auswählen",
+ "network_rules_bulk_actions": "Massenaktionen",
+ "network_rules_selected_count": "{{count}} ausgewählt",
+ "network_rules_bulk_enable": "Aktivieren",
+ "network_rules_bulk_disable": "Deaktivieren",
+ "network_rules_bulk_delete": "Löschen",
+ "network_rules_clear_selection": "Auswahl aufheben",
+ "network_rules_select_rule": "{{name}} auswählen",
+ "network_rules_drag_handle": "{{name}} neu anordnen",
+ "network_rules_drag_instructions": "Drücke die Leertaste, um die Regel aufzunehmen, bewege sie mit den Pfeiltasten, drücke erneut die Leertaste zum Ablegen oder Escape zum Abbrechen.",
+ "network_rules_drag_picked_up": "„{{name}}“ aufgenommen, Position {{position}} von {{total}}.",
+ "network_rules_drag_moved_over": "„{{name}}“ wird auf Position {{position}} verschoben.",
+ "network_rules_drag_dropped": "„{{name}}“ wurde auf Position {{position}} abgelegt.",
+ "network_rules_drag_cancelled": "Ziehen abgebrochen, „{{name}}“ bleibt auf Position {{position}}.",
+ "network_rules_enable_rule": "{{name}} aktivieren",
+ "network_rules_row_menu": "Weitere Aktionen",
+ "network_rules_modified_at": "Geändert {{time}}",
+ "network_rules_all_websites": "Alle Websites",
+ "network_rules_move_top": "Ganz nach oben",
+ "network_rules_move_bottom": "Ganz nach unten",
+ "network_rules_move_to": "Verschieben nach…",
+ "network_rules_move_to_title": "An Position verschieben",
+ "network_rules_move_to_label": "Position (1–{{total}})",
+ "network_rules_move_to_confirm": "Verschieben",
+ "network_rules_cancel": "Abbrechen",
+ "network_rules_confirm": "Fortfahren",
+ "network_rules_filter_active": "Gefiltert · {{matched}} von {{total}} Regeln",
+ "network_rules_filter_drag_paused": "Sortieren braucht die vollständige Liste, Ziehen ist daher pausiert",
+ "network_rules_clear_filters": "Filter zurücksetzen",
+ "network_rules_empty_title": "Noch keine Netzwerkregeln",
+ "network_rules_empty_description": "Beginne mit einem gängigen Szenario oder lege selbst eine Regel an.",
+ "network_rules_no_results_title": "Keine passenden Regeln",
+ "network_rules_no_results_description": "Versuche ein anderes Stichwort oder setze die Filter zurück.",
+ "network_rules_apply_error_title": "Die Regeln konnten nicht im Browser angewendet werden",
+ "network_rules_apply_error_detail": "Seiten verwenden weiterhin die vorherige Version der Regeln.",
+ "network_rules_retry": "Erneut versuchen",
+ "network_rules_load_error": "Netzwerkregeln konnten nicht geladen werden. Bitte erneut versuchen.",
+ "network_rules_unsupported_schema": "Dieses Datenformat für Netzwerkregeln wird noch nicht unterstützt.",
+ "network_rules_footer_total": "{{count}} Regeln · {{enabled}} aktiv",
+ "network_rules_quota_total": "Kontingent dynamischer Regeln {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Header ändern / Umleiten / Zulassen {{used}}/{{limit}}",
+ "network_rules_prev_page": "Vorherige Seite",
+ "network_rules_next_page": "Nächste Seite",
+ "network_rules_reorder_failed": "Die Reihenfolge konnte nicht gespeichert werden und wurde zurückgesetzt.",
+ "network_rules_rule_saved": "Regel angewendet",
+ "network_rules_rule_saved_apply_failed": "Regel gespeichert, aber die Browserregeln konnten nicht aktualisiert werden.",
+ "network_rules_revision_conflict": "Diese Seite ist nicht aktuell. Lade die neuesten Regeln und versuche es erneut.",
+ "network_rules_storage_error": "Die Änderung konnte nicht gespeichert werden.",
+ "network_rules_confirm_all_sites_title": "Alle Websites betreffen?",
+ "network_rules_confirm_all_sites_description": "Das schwächt den eingebauten Schutz jeder passenden Website. Fortfahren?",
+ "network_rule_template_csp": "CSP entfernen",
+ "network_rule_template_csp_desc": "Lässt Skripte laufen, die von der CSP einer Website blockiert werden.",
+ "network_rule_template_user_agent": "User-Agent ändern",
+ "network_rule_template_user_agent_desc": "Gibt ein anderes Gerät oder einen anderen Browser an.",
+ "network_rule_template_referer": "Referer ändern",
+ "network_rule_template_referer_desc": "Schreibt die Herkunftsadresse um oder entfernt sie.",
+ "network_rule_template_response_headers": "Antwort-Header ändern",
+ "network_rule_template_response_headers_desc": "Fügt beliebige Antwort-Header hinzu, ändert oder entfernt sie – nicht nur CSP.",
+ "network_rule_template_block": "Anfragen blockieren",
+ "network_rule_template_block_desc": "Verhindert, dass passende Anfragen gesendet werden.",
+ "network_rule_template_redirect": "Anfragen umleiten",
+ "network_rule_template_redirect_desc": "Leitet passende Anfragen an eine andere Adresse.",
+ "network_rule_template_custom": "Benutzerdefiniert",
+ "network_rule_template_custom_desc": "Bedingung und Aktion direkt bearbeiten, inklusive Änderungen an Anfrage-Headern.",
+ "network_rules_edit_title": "Regel bearbeiten",
+ "network_rules_back_to_templates": "Zurück zu den Vorlagen",
+ "network_rules_change_template": "Typ wechseln",
+ "network_rules_save": "Speichern",
+ "network_rules_edit": "Bearbeiten",
+ "network_rules_delete": "Löschen",
+ "network_rules_confirm_delete_title": "Diese Regel löschen?",
+ "network_rules_confirm_delete_description": "Das lässt sich nicht rückgängig machen. Wenn du sie nur pausieren willst, deaktiviere sie stattdessen.",
+ "network_rules_confirm_delete_action": "Regel löschen",
+ "network_rules_confirm_bulk_delete_title": "{{count}} ausgewählte Regeln löschen?",
+ "network_rules_rule_deleted": "Regel gelöscht.",
+ "network_rules_field_scope": "Websites",
+ "network_rules_scope_help": "Eine Domain pro Zeile; Subdomains sind enthalten.",
+ "network_rules_all_sites_warning": "Höheres Risiko; vor dem Speichern wird nachgefragt.",
+ "network_rules_field_name": "Regelname",
+ "network_rules_optional": "(optional)",
+ "network_rules_field_removed_headers": "Zu entfernende Antwort-Header",
+ "network_rules_csp_include_xfo": "X-Frame-Options ebenfalls entfernen",
+ "network_rules_field_user_agent": "User-Agent-Wert",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer-Verhalten",
+ "network_rules_referer_set": "Auf feste Adresse setzen",
+ "network_rules_referer_remove": "Referer entfernen",
+ "network_rules_field_redirect_url": "Umleiten nach",
+ "network_rules_field_action_type": "Aktionstyp",
+ "network_rules_field_request_headers": "Änderungen an Anfrage-Headern",
+ "network_rules_field_response_headers": "Änderungen an Antwort-Headern",
+ "network_rules_header_name": "Header-Name",
+ "network_rules_header_operation": "Aktion",
+ "network_rules_header_value": "Wert",
+ "network_rules_header_op_set": "Setzen",
+ "network_rules_header_op_append": "Anhängen",
+ "network_rules_header_op_remove": "Entfernen",
+ "network_rules_add_header": "Header hinzufügen",
+ "network_rules_remove_header": "Diesen Header entfernen",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host und Origin können nicht geändert werden.",
+ "network_rules_try_it": "URL testen",
+ "network_rules_try_match_effect": "Passt · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "entfernt {{count}} Antwort-Header",
+ "network_rules_try_effect_modify_request_headers": "ändert {{count}} Anfrage-Header",
+ "network_rules_try_effect_modify_response_headers": "ändert {{count}} Antwort-Header",
+ "network_rules_try_effect_block": "blockiert die Anfrage",
+ "network_rules_try_effect_redirect": "leitet nach {{url}} um",
+ "network_rules_try_effect_allow": "lässt die Anfrage zu und überspringt spätere Regeln",
+ "network_rules_try_scope_only": "Die URL liegt im Geltungsbereich, aber die unter „Erweitert“ gewählten Ressourcentypen oder Anfragemethoden decken diese GET-Anfrage des Hauptdokuments nicht ab.",
+ "network_rules_try_no_match": "Passt nicht zu dieser Regel",
+ "network_rules_try_invalid": "Gib eine vollständige http(s)-Adresse ein.",
+ "network_rules_advanced": "Erweitert",
+ "network_rules_field_resource_types": "Ressourcentypen",
+ "network_rules_field_request_methods": "Anfragemethoden",
+ "network_rules_field_excluded_domains": "Ausgeschlossene Domains",
+ "network_rules_error_domain_required": "Gib mindestens eine Website ein.",
+ "network_rules_error_domain_invalid": "Gib einen gültigen HTTP(S)-Hostnamen ein.",
+ "network_rules_error_domain_credentials": "Entferne Benutzername und Passwort aus der URL.",
+ "network_rules_error_domain_wildcard": "Gib stattdessen example.com ein; Subdomains sind automatisch enthalten.",
+ "network_rules_error_domain_single_label": "Gib eine Domain mit Punkt ein, z. B. example.com.",
+ "network_rules_error_domain_too_long": "Diese Domain ist zu lang.",
+ "network_rules_error_domain_count_invalid": "Verwende 1 bis 100 Domains pro Regel.",
+ "network_rules_error_header_name_invalid": "Gib einen gültigen Header-Namen ein.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host und Origin würden die Identität des Aufrufers preisgeben oder fälschen und lassen sich daher nicht ändern.",
+ "network_rules_error_header_value_required": "Gib einen Wert für diesen Header ein.",
+ "network_rules_error_redirect_url_invalid": "Gib eine vollständige Adresse ein, die mit http(s) beginnt.",
+ "network_rules_card_description": "Ändern, wie der Browser passende Anfragen behandelt: CSP entfernen, Header umschreiben, blockieren oder umleiten.",
+ "network_rules_state_active": "Aktiv",
+ "network_rules_state_paused": "Pausiert",
+ "network_rules_state_failed": "Nicht angewendet",
+ "network_rules_summary_counts": "{{enabled}} aktiv · {{total}} Regeln insgesamt",
+ "network_rules_preview_more": "{{count}} weitere …",
+ "network_rules_manage": "Regeln verwalten",
+ "network_rules_paused_title": "Alle Regeln sind pausiert",
+ "network_rules_paused_description": "Die Regeln bleiben erhalten und greifen wieder, sobald der Hauptschalter erneut aktiviert wird.",
+ "network_rules_reload_hint": "Bereits geöffnete Seiten müssen neu geladen werden.",
+ "network_rules_reload_tabs": "Tabs neu laden",
+ "network_rules_match_description": "Gib eine URL ein, um zu sehen, wie die aktuell aktiven Regeln sie behandeln.",
+ "network_rules_match_paused": "Der Hauptschalter ist aus, diese Regeln greifen derzeit nicht. Die Ergebnisse unten simulieren den Zustand nach dem Einschalten.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Endergebnis",
+ "network_rules_match_hits": "Passende Regeln (in Listenreihenfolge)",
+ "network_rules_match_note": "Simulation auf Basis der aktuellen Regeln; die tatsächlichen Treffer des Browsers sind nicht enthalten.",
+ "network_rules_match_close": "Schließen",
+ "network_rules_outcome_none": "Kein Treffer",
+ "network_rules_outcome_blocked": "Blockiert",
+ "network_rules_outcome_redirected": "Umgeleitet",
+ "network_rules_outcome_allowed": "Erlaubt",
+ "network_rules_outcome_modified": "Header geändert",
+ "network_rules_outcome_blocked_detail": "Die Anfrage wird blockiert. Blockieren bricht die gesamte Anfrage ab, daher läuft keine weitere passende Regel.",
+ "network_rules_outcome_redirected_detail": "Die Anfrage wird zu {{url}} umgeleitet.",
+ "network_rules_outcome_allowed_detail": "Die Anfrage wird durchgelassen; weiter unten stehende Regeln laufen nicht mehr.",
+ "network_rules_outcome_modified_detail": "{{count}} Regel(n) ändern Anfrage- oder Antwortheader, angewendet in Listenreihenfolge.",
+ "network_rules_outcome_none_detail": "Keine der aktiven Regeln passt auf diese Anfrage.",
+ "network_rules_hit_applied": "Wird ausgeführt",
+ "network_rules_hit_blocked": "Die Anfrage wird von #{{position}} blockiert, daher läuft diese Regel nicht",
+ "network_rules_hit_overridden": "#{{position}} steht weiter oben und hat das Ergebnis bereits bestimmt, daher läuft diese Regel nicht",
+ "network_rules_hit_allowed": "#{{position}} steht weiter oben und lässt die Anfrage durch, daher läuft diese Regel nicht",
+ "network_rule_resource_main_frame": "Hauptframe",
+ "network_rule_resource_sub_frame": "Unterframe",
+ "network_rule_resource_stylesheet": "Stylesheet",
+ "network_rule_resource_script": "Skript",
+ "network_rule_resource_image": "Bild",
+ "network_rule_resource_font": "Schriftart",
+ "network_rule_resource_object": "Objekt",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP-Bericht",
+ "network_rule_resource_media": "Medien",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Sonstiges"
}
diff --git a/src/locales/en-US/tools.json b/src/locales/en-US/tools.json
index 1e8064ed5..9108e86fc 100644
--- a/src/locales/en-US/tools.json
+++ b/src/locales/en-US/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Local Backup",
"cloud_backup": "Cloud Backup",
"auto_backup": "Auto Backup",
- "data_migration": "Data Migration"
+ "data_migration": "Data Migration",
+ "network_rules_title": "Network rules",
+ "network_rules_back": "Back to tools",
+ "network_rules_master_switch": "Master switch",
+ "network_rules_search_placeholder": "Search rule names or domains",
+ "network_rules_filter_action": "Action",
+ "network_rules_filter_status": "Status",
+ "network_rules_filter_all_actions": "All actions",
+ "network_rules_filter_all_statuses": "All statuses",
+ "network_rules_status_enabled": "Enabled",
+ "network_rules_status_disabled": "Disabled",
+ "network_rules_test_match": "Test match",
+ "network_rules_new_rule": "New rule",
+ "network_rule_action_remove_response_headers": "Remove response headers",
+ "network_rule_action_modify_request_headers": "Edit request headers",
+ "network_rule_action_modify_response_headers": "Edit response headers",
+ "network_rule_action_block": "Block",
+ "network_rule_action_redirect": "Redirect",
+ "network_rule_action_allow": "Allow",
+ "network_rules_column_name": "Name",
+ "network_rules_column_action": "Action",
+ "network_rules_column_scope": "Scope",
+ "network_rules_column_order": "Order",
+ "network_rules_select_all": "Select rules on this page",
+ "network_rules_bulk_actions": "Bulk actions",
+ "network_rules_selected_count": "{{count}} selected",
+ "network_rules_bulk_enable": "Enable",
+ "network_rules_bulk_disable": "Disable",
+ "network_rules_bulk_delete": "Delete",
+ "network_rules_clear_selection": "Clear selection",
+ "network_rules_select_rule": "Select {{name}}",
+ "network_rules_drag_handle": "Reorder {{name}}",
+ "network_rules_drag_instructions": "Press space to pick up the rule, use the arrow keys to move it, press space again to drop it, or press escape to cancel.",
+ "network_rules_drag_picked_up": "Picked up “{{name}}”, position {{position}} of {{total}}.",
+ "network_rules_drag_moved_over": "“{{name}}” will move to position {{position}}.",
+ "network_rules_drag_dropped": "“{{name}}” was dropped at position {{position}}.",
+ "network_rules_drag_cancelled": "Dragging cancelled, “{{name}}” stays at position {{position}}.",
+ "network_rules_enable_rule": "Enable {{name}}",
+ "network_rules_row_menu": "More actions",
+ "network_rules_modified_at": "Modified {{time}}",
+ "network_rules_all_websites": "All websites",
+ "network_rules_move_top": "Move to top",
+ "network_rules_move_bottom": "Move to bottom",
+ "network_rules_move_to": "Move to…",
+ "network_rules_move_to_title": "Move to position",
+ "network_rules_move_to_label": "Position (1–{{total}})",
+ "network_rules_move_to_confirm": "Move",
+ "network_rules_cancel": "Cancel",
+ "network_rules_confirm": "Continue",
+ "network_rules_filter_active": "Filtered · {{matched}} of {{total}} rules",
+ "network_rules_filter_drag_paused": "Reordering needs the full list, so dragging is paused",
+ "network_rules_clear_filters": "Clear filters",
+ "network_rules_empty_title": "No network rules yet",
+ "network_rules_empty_description": "Start from a common scenario, or create one yourself.",
+ "network_rules_no_results_title": "No matching rules",
+ "network_rules_no_results_description": "Try another keyword, or clear the filters.",
+ "network_rules_apply_error_title": "The rules could not be applied to the browser",
+ "network_rules_apply_error_detail": "Pages are still using the previous version of the rules.",
+ "network_rules_retry": "Retry",
+ "network_rules_load_error": "Network rules could not be loaded. Please try again.",
+ "network_rules_unsupported_schema": "This network rules data format is not supported yet.",
+ "network_rules_footer_total": "{{count}} rules · {{enabled}} enabled",
+ "network_rules_quota_total": "Dynamic rule quota {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Header rewrite / redirect / allow {{used}}/{{limit}}",
+ "network_rules_prev_page": "Previous page",
+ "network_rules_next_page": "Next page",
+ "network_rules_reorder_failed": "The order could not be saved and was restored.",
+ "network_rules_rule_saved": "Rule applied",
+ "network_rules_rule_saved_apply_failed": "Rule saved, but the browser rules could not be updated.",
+ "network_rules_revision_conflict": "This page is out of date. Reload the latest rules and try again.",
+ "network_rules_storage_error": "The change could not be saved.",
+ "network_rules_confirm_all_sites_title": "Affect all websites?",
+ "network_rules_confirm_all_sites_description": "This weakens the built-in protection of every matching website. Continue?",
+ "network_rule_template_csp": "Remove CSP",
+ "network_rule_template_csp_desc": "Let scripts blocked by a site's CSP run.",
+ "network_rule_template_user_agent": "Change User-Agent",
+ "network_rule_template_user_agent_desc": "Present a different device or browser.",
+ "network_rule_template_referer": "Change Referer",
+ "network_rule_template_referer_desc": "Rewrite or drop the referring page address.",
+ "network_rule_template_response_headers": "Change response headers",
+ "network_rule_template_response_headers_desc": "Add, change or drop any response header, not just CSP.",
+ "network_rule_template_block": "Block requests",
+ "network_rule_template_block_desc": "Stop matching requests from being sent.",
+ "network_rule_template_redirect": "Redirect requests",
+ "network_rule_template_redirect_desc": "Send matching requests to another address.",
+ "network_rule_template_custom": "Custom",
+ "network_rule_template_custom_desc": "Edit the condition and action directly, including request-header changes.",
+ "network_rules_edit_title": "Edit rule",
+ "network_rules_back_to_templates": "Back to templates",
+ "network_rules_change_template": "Change type",
+ "network_rules_save": "Save",
+ "network_rules_edit": "Edit",
+ "network_rules_delete": "Delete",
+ "network_rules_confirm_delete_title": "Delete this rule?",
+ "network_rules_confirm_delete_description": "This cannot be undone. If you only need a break from it, disable it instead.",
+ "network_rules_confirm_delete_action": "Delete rule",
+ "network_rules_confirm_bulk_delete_title": "Delete {{count}} selected rules?",
+ "network_rules_rule_deleted": "Rule deleted.",
+ "network_rules_field_scope": "Websites",
+ "network_rules_scope_help": "One domain per line; subdomains are included.",
+ "network_rules_all_sites_warning": "Higher risk; you will be asked to confirm before saving.",
+ "network_rules_field_name": "Rule name",
+ "network_rules_optional": "(optional)",
+ "network_rules_field_removed_headers": "Response headers to remove",
+ "network_rules_csp_include_xfo": "Also remove X-Frame-Options",
+ "network_rules_field_user_agent": "User-Agent value",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer handling",
+ "network_rules_referer_set": "Set to a fixed address",
+ "network_rules_referer_remove": "Remove the Referer",
+ "network_rules_field_redirect_url": "Redirect to",
+ "network_rules_field_action_type": "Action type",
+ "network_rules_field_request_headers": "Request header changes",
+ "network_rules_field_response_headers": "Response header changes",
+ "network_rules_header_name": "Header name",
+ "network_rules_header_operation": "Operation",
+ "network_rules_header_value": "Value",
+ "network_rules_header_op_set": "Set",
+ "network_rules_header_op_append": "Append",
+ "network_rules_header_op_remove": "Remove",
+ "network_rules_add_header": "Add a header",
+ "network_rules_remove_header": "Remove this header",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host and Origin cannot be modified.",
+ "network_rules_try_it": "Try a URL",
+ "network_rules_try_match_effect": "Matches · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "removes {{count}} response header(s)",
+ "network_rules_try_effect_modify_request_headers": "rewrites {{count}} request header(s)",
+ "network_rules_try_effect_modify_response_headers": "rewrites {{count}} response header(s)",
+ "network_rules_try_effect_block": "blocks the request",
+ "network_rules_try_effect_redirect": "redirects to {{url}}",
+ "network_rules_try_effect_allow": "allows the request and skips later rules",
+ "network_rules_try_scope_only": "The URL is in scope, but the resource types or request methods set under Advanced do not cover this main-document GET request.",
+ "network_rules_try_no_match": "Does not match this rule",
+ "network_rules_try_invalid": "Enter a full http(s) address.",
+ "network_rules_advanced": "Advanced",
+ "network_rules_field_resource_types": "Resource types",
+ "network_rules_field_request_methods": "Request methods",
+ "network_rules_field_excluded_domains": "Excluded domains",
+ "network_rules_error_domain_required": "Enter at least one website.",
+ "network_rules_error_domain_invalid": "Enter a valid HTTP(S) website hostname.",
+ "network_rules_error_domain_credentials": "Remove the username and password from the URL.",
+ "network_rules_error_domain_wildcard": "Enter example.com instead; subdomains are included automatically.",
+ "network_rules_error_domain_single_label": "Enter a domain with a dot, such as example.com.",
+ "network_rules_error_domain_too_long": "This domain is too long.",
+ "network_rules_error_domain_count_invalid": "Use between 1 and 100 domains per rule.",
+ "network_rules_error_header_name_invalid": "Enter a valid header name.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host and Origin would leak or forge the caller's identity, so they cannot be changed.",
+ "network_rules_error_header_value_required": "Enter a value for this header.",
+ "network_rules_error_redirect_url_invalid": "Enter a full address starting with http(s).",
+ "network_rules_card_description": "Change how the browser handles matching requests: remove CSP, rewrite headers, block, or redirect.",
+ "network_rules_state_active": "Active",
+ "network_rules_state_paused": "Paused",
+ "network_rules_state_failed": "Not applied",
+ "network_rules_summary_counts": "{{enabled}} enabled · {{total}} rules total",
+ "network_rules_preview_more": "{{count}} more…",
+ "network_rules_manage": "Manage rules",
+ "network_rules_paused_title": "All rules are paused",
+ "network_rules_paused_description": "Rules are kept and resume as soon as the master switch is turned back on.",
+ "network_rules_reload_hint": "Open pages need a refresh before the change applies.",
+ "network_rules_reload_tabs": "Reload tabs",
+ "network_rules_match_description": "Enter a URL to see how the currently enabled rules would handle it.",
+ "network_rules_match_paused": "The master switch is off, so these rules are not in effect; the results below simulate turning it back on.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Final outcome",
+ "network_rules_match_hits": "Matching rules (in list order)",
+ "network_rules_match_note": "Simulated from the current rules; it does not include the browser's actual matched-rule records.",
+ "network_rules_match_close": "Close",
+ "network_rules_outcome_none": "No match",
+ "network_rules_outcome_blocked": "Blocked",
+ "network_rules_outcome_redirected": "Redirected",
+ "network_rules_outcome_allowed": "Allowed",
+ "network_rules_outcome_modified": "Headers modified",
+ "network_rules_outcome_blocked_detail": "The request is blocked. Blocking short-circuits the whole request, so no other matching rule runs.",
+ "network_rules_outcome_redirected_detail": "The request is redirected to {{url}}.",
+ "network_rules_outcome_allowed_detail": "The request is allowed through; rules further down the list no longer run.",
+ "network_rules_outcome_modified_detail": "{{count}} rule(s) rewrite request or response headers, applied in list order.",
+ "network_rules_outcome_none_detail": "None of the enabled rules match this request.",
+ "network_rules_hit_applied": "Runs",
+ "network_rules_hit_blocked": "The request is blocked by #{{position}}, so this rule does not run",
+ "network_rules_hit_overridden": "#{{position}} comes first and already decided the outcome, so this rule does not run",
+ "network_rules_hit_allowed": "#{{position}} comes first and lets the request through, so this rule does not run",
+ "network_rule_resource_main_frame": "Main frame",
+ "network_rule_resource_sub_frame": "Sub frame",
+ "network_rule_resource_stylesheet": "Stylesheet",
+ "network_rule_resource_script": "Script",
+ "network_rule_resource_image": "Image",
+ "network_rule_resource_font": "Font",
+ "network_rule_resource_object": "Object",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP report",
+ "network_rule_resource_media": "Media",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Other"
}
diff --git a/src/locales/ja-JP/tools.json b/src/locales/ja-JP/tools.json
index 778a8a710..35f79bfa6 100644
--- a/src/locales/ja-JP/tools.json
+++ b/src/locales/ja-JP/tools.json
@@ -14,5 +14,200 @@
"local_backup": "ローカルバックアップ",
"cloud_backup": "クラウドバックアップ",
"auto_backup": "自動バックアップ",
- "data_migration": "データ移行"
+ "data_migration": "データ移行",
+ "network_rules_title": "ネットワークルール",
+ "network_rules_back": "ツールに戻る",
+ "network_rules_master_switch": "メインスイッチ",
+ "network_rules_search_placeholder": "ルール名またはドメインを検索",
+ "network_rules_filter_action": "アクション",
+ "network_rules_filter_status": "状態",
+ "network_rules_filter_all_actions": "すべてのアクション",
+ "network_rules_filter_all_statuses": "すべての状態",
+ "network_rules_status_enabled": "有効",
+ "network_rules_status_disabled": "無効",
+ "network_rules_test_match": "マッチをテスト",
+ "network_rules_new_rule": "ルールを作成",
+ "network_rule_action_remove_response_headers": "レスポンスヘッダーを削除",
+ "network_rule_action_modify_request_headers": "リクエストヘッダーを変更",
+ "network_rule_action_modify_response_headers": "レスポンスヘッダーを変更",
+ "network_rule_action_block": "ブロック",
+ "network_rule_action_redirect": "リダイレクト",
+ "network_rule_action_allow": "許可",
+ "network_rules_column_name": "名前",
+ "network_rules_column_action": "アクション",
+ "network_rules_column_scope": "適用範囲",
+ "network_rules_column_order": "順序",
+ "network_rules_select_all": "このページのルールを選択",
+ "network_rules_bulk_actions": "一括操作",
+ "network_rules_selected_count": "{{count}} 件を選択中",
+ "network_rules_bulk_enable": "有効化",
+ "network_rules_bulk_disable": "無効化",
+ "network_rules_bulk_delete": "削除",
+ "network_rules_clear_selection": "選択を解除",
+ "network_rules_select_rule": "{{name}} を選択",
+ "network_rules_drag_handle": "{{name}} の順序を変更",
+ "network_rules_drag_instructions": "スペースキーでルールをつかみ、矢印キーで移動し、もう一度スペースキーで置きます。Esc キーで取り消します。",
+ "network_rules_drag_picked_up": "「{{name}}」をつかみました。現在 {{total}} 件中 {{position}} 番目です。",
+ "network_rules_drag_moved_over": "「{{name}}」は {{position}} 番目に移動します。",
+ "network_rules_drag_dropped": "「{{name}}」を {{position}} 番目に置きました。",
+ "network_rules_drag_cancelled": "ドラッグを取り消しました。「{{name}}」は {{position}} 番目のままです。",
+ "network_rules_enable_rule": "{{name}} を有効化",
+ "network_rules_row_menu": "その他の操作",
+ "network_rules_modified_at": "{{time}}に更新",
+ "network_rules_all_websites": "すべてのサイト",
+ "network_rules_move_top": "先頭へ移動",
+ "network_rules_move_bottom": "末尾へ移動",
+ "network_rules_move_to": "指定位置へ移動…",
+ "network_rules_move_to_title": "指定位置へ移動",
+ "network_rules_move_to_label": "位置(1–{{total}})",
+ "network_rules_move_to_confirm": "移動",
+ "network_rules_cancel": "キャンセル",
+ "network_rules_confirm": "続行",
+ "network_rules_filter_active": "絞り込み中 · {{total}} 件中 {{matched}} 件",
+ "network_rules_filter_drag_paused": "並べ替えには一覧全体が必要なため、ドラッグを一時停止しています",
+ "network_rules_clear_filters": "絞り込みを解除",
+ "network_rules_empty_title": "ネットワークルールはまだありません",
+ "network_rules_empty_description": "よくある用途から始めるか、自分で作成できます。",
+ "network_rules_no_results_title": "一致するルールがありません",
+ "network_rules_no_results_description": "別のキーワードを試すか、絞り込みを解除してください。",
+ "network_rules_apply_error_title": "ルールをブラウザーに適用できませんでした",
+ "network_rules_apply_error_detail": "ページでは以前のバージョンのルールが使われています。",
+ "network_rules_retry": "再試行",
+ "network_rules_load_error": "ネットワークルールを読み込めませんでした。再試行してください。",
+ "network_rules_unsupported_schema": "このネットワークルールのデータ形式にはまだ対応していません。",
+ "network_rules_footer_total": "全 {{count}} 件 · {{enabled}} 件有効",
+ "network_rules_quota_total": "動的ルールの上限 {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "ヘッダー変更 / リダイレクト / 許可 {{used}}/{{limit}}",
+ "network_rules_prev_page": "前のページ",
+ "network_rules_next_page": "次のページ",
+ "network_rules_reorder_failed": "順序を保存できませんでした。元の順序に戻しました。",
+ "network_rules_rule_saved": "ルールが有効になりました",
+ "network_rules_rule_saved_apply_failed": "ルールは保存されましたが、ブラウザーのルールを更新できませんでした。",
+ "network_rules_revision_conflict": "このページは最新ではありません。最新のルールを読み込んでからやり直してください。",
+ "network_rules_storage_error": "変更を保存できませんでした。",
+ "network_rules_confirm_all_sites_title": "すべてのサイトに影響しますか?",
+ "network_rules_confirm_all_sites_description": "一致するすべてのサイトの保護が弱まります。続行しますか?",
+ "network_rule_template_csp": "CSP を削除",
+ "network_rule_template_csp_desc": "サイトの CSP にブロックされたスクリプトを動かします。",
+ "network_rule_template_user_agent": "User-Agent を変更",
+ "network_rule_template_user_agent_desc": "別の端末やブラウザーとして振る舞います。",
+ "network_rule_template_referer": "Referer を変更",
+ "network_rule_template_referer_desc": "参照元ページのアドレスを書き換えるか削除します。",
+ "network_rule_template_response_headers": "レスポンスヘッダーを変更",
+ "network_rule_template_response_headers_desc": "CSP に限らず任意のレスポンスヘッダーを追加・変更・削除します。",
+ "network_rule_template_block": "リクエストをブロック",
+ "network_rule_template_block_desc": "一致するリクエストの送信を止めます。",
+ "network_rule_template_redirect": "リクエストをリダイレクト",
+ "network_rule_template_redirect_desc": "一致するリクエストを別のアドレスへ送ります。",
+ "network_rule_template_custom": "カスタム",
+ "network_rule_template_custom_desc": "条件と動作を直接編集します。リクエストヘッダーの変更もここから。",
+ "network_rules_edit_title": "ルールを編集",
+ "network_rules_back_to_templates": "テンプレートに戻る",
+ "network_rules_change_template": "種類を変更",
+ "network_rules_save": "保存",
+ "network_rules_edit": "編集",
+ "network_rules_delete": "削除",
+ "network_rules_confirm_delete_title": "このルールを削除しますか?",
+ "network_rules_confirm_delete_description": "元に戻せません。一時的に使わないだけなら、無効にしてください。",
+ "network_rules_confirm_delete_action": "ルールを削除",
+ "network_rules_confirm_bulk_delete_title": "選択した {{count}} 件のルールを削除しますか?",
+ "network_rules_rule_deleted": "ルールを削除しました。",
+ "network_rules_field_scope": "対象サイト",
+ "network_rules_scope_help": "1 行に 1 つのドメイン。サブドメインも含まれます。",
+ "network_rules_all_sites_warning": "リスクが高いため、保存前に確認します。",
+ "network_rules_field_name": "ルール名",
+ "network_rules_optional": "(任意)",
+ "network_rules_field_removed_headers": "削除するレスポンスヘッダー",
+ "network_rules_csp_include_xfo": "X-Frame-Options も削除する",
+ "network_rules_field_user_agent": "User-Agent の値",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer の扱い",
+ "network_rules_referer_set": "指定したアドレスにする",
+ "network_rules_referer_remove": "Referer を削除",
+ "network_rules_field_redirect_url": "リダイレクト先",
+ "network_rules_field_action_type": "動作の種類",
+ "network_rules_field_request_headers": "リクエストヘッダーの変更",
+ "network_rules_field_response_headers": "レスポンスヘッダーの変更",
+ "network_rules_header_name": "ヘッダー名",
+ "network_rules_header_operation": "操作",
+ "network_rules_header_value": "値",
+ "network_rules_header_op_set": "設定",
+ "network_rules_header_op_append": "追加",
+ "network_rules_header_op_remove": "削除",
+ "network_rules_add_header": "ヘッダーを追加",
+ "network_rules_remove_header": "このヘッダーを削除",
+ "network_rules_header_denied_note": "Cookie、Authorization、Host、Origin は変更できません。",
+ "network_rules_try_it": "URL を試す",
+ "network_rules_try_match_effect": "一致 · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "レスポンスヘッダーを {{count}} 件削除します",
+ "network_rules_try_effect_modify_request_headers": "リクエストヘッダーを {{count}} 件書き換えます",
+ "network_rules_try_effect_modify_response_headers": "レスポンスヘッダーを {{count}} 件書き換えます",
+ "network_rules_try_effect_block": "リクエストをブロックします",
+ "network_rules_try_effect_redirect": "{{url}} にリダイレクトします",
+ "network_rules_try_effect_allow": "リクエストを許可し、以降のルールをスキップします",
+ "network_rules_try_scope_only": "URL は対象範囲内ですが、「詳細設定」のリソース種別やリクエストメソッドが、このメインドキュメントの GET リクエストを含んでいません。",
+ "network_rules_try_no_match": "このルールに一致しません",
+ "network_rules_try_invalid": "完全な http(s) アドレスを入力してください。",
+ "network_rules_advanced": "詳細設定",
+ "network_rules_field_resource_types": "リソースの種類",
+ "network_rules_field_request_methods": "リクエストメソッド",
+ "network_rules_field_excluded_domains": "除外するドメイン",
+ "network_rules_error_domain_required": "少なくとも 1 つのサイトを入力してください。",
+ "network_rules_error_domain_invalid": "有効な HTTP(S) のホスト名を入力してください。",
+ "network_rules_error_domain_credentials": "URL からユーザー名とパスワードを削除してください。",
+ "network_rules_error_domain_wildcard": "example.com のように入力してください。サブドメインは自動的に含まれます。",
+ "network_rules_error_domain_single_label": "example.com のようにドットを含むドメインを入力してください。",
+ "network_rules_error_domain_too_long": "このドメインは長すぎます。",
+ "network_rules_error_domain_count_invalid": "1 つのルールにつきドメインは 1〜100 個にしてください。",
+ "network_rules_error_header_name_invalid": "有効なヘッダー名を入力してください。",
+ "network_rules_error_header_denied": "Cookie、Authorization、Host、Origin は呼び出し元の身元を漏らしたり偽装したりするため、変更できません。",
+ "network_rules_error_header_value_required": "このヘッダーの値を入力してください。",
+ "network_rules_error_redirect_url_invalid": "http(s) で始まる完全なアドレスを入力してください。",
+ "network_rules_card_description": "一致するリクエストのブラウザーの扱いを変更します:CSP の削除、ヘッダーの書き換え、ブロック、リダイレクト。",
+ "network_rules_state_active": "有効",
+ "network_rules_state_paused": "一時停止中",
+ "network_rules_state_failed": "未適用",
+ "network_rules_summary_counts": "{{enabled}} 件有効 · 全 {{total}} 件",
+ "network_rules_preview_more": "他 {{count}} 件…",
+ "network_rules_manage": "ルールを管理",
+ "network_rules_paused_title": "すべてのルールが一時停止中です",
+ "network_rules_paused_description": "ルールは保持され、マスタースイッチを再度オンにすると即座に再開します。",
+ "network_rules_reload_hint": "開いているページは再読み込みするまで反映されません。",
+ "network_rules_reload_tabs": "タブを再読み込み",
+ "network_rules_match_description": "URL を入力すると、現在有効なルールがどう処理するかを確認できます。",
+ "network_rules_match_paused": "メインスイッチがオフのため、これらのルールは現在適用されていません。以下は再びオンにした場合のシミュレーション結果です。",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "最終結果",
+ "network_rules_match_hits": "一致したルール(リスト順)",
+ "network_rules_match_note": "現在のルールに基づくシミュレーション結果で、ブラウザーの実際の一致記録は含みません。",
+ "network_rules_match_close": "閉じる",
+ "network_rules_outcome_none": "一致なし",
+ "network_rules_outcome_blocked": "ブロック",
+ "network_rules_outcome_redirected": "リダイレクト",
+ "network_rules_outcome_allowed": "許可",
+ "network_rules_outcome_modified": "ヘッダー変更",
+ "network_rules_outcome_blocked_detail": "リクエストはブロックされます。ブロックはリクエスト全体を打ち切るため、他の一致ルールは実行されません。",
+ "network_rules_outcome_redirected_detail": "リクエストは {{url}} にリダイレクトされます。",
+ "network_rules_outcome_allowed_detail": "リクエストは許可され、リスト内でより下位のルールは実行されません。",
+ "network_rules_outcome_modified_detail": "{{count}} 件のルールがリスト順にリクエスト/レスポンスヘッダーを書き換えます。",
+ "network_rules_outcome_none_detail": "有効なルールはどれもこのリクエストに一致しません。",
+ "network_rules_hit_applied": "実行されます",
+ "network_rules_hit_blocked": "リクエストは #{{position}} でブロックされるため実行されません",
+ "network_rules_hit_overridden": "先に並ぶ #{{position}} が結果を決めたため実行されません",
+ "network_rules_hit_allowed": "先に並ぶ #{{position}} がリクエストを許可したため実行されません",
+ "network_rule_resource_main_frame": "メインフレーム",
+ "network_rule_resource_sub_frame": "サブフレーム",
+ "network_rule_resource_stylesheet": "スタイルシート",
+ "network_rule_resource_script": "スクリプト",
+ "network_rule_resource_image": "画像",
+ "network_rule_resource_font": "フォント",
+ "network_rule_resource_object": "オブジェクト",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP レポート",
+ "network_rule_resource_media": "メディア",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "その他"
}
diff --git a/src/locales/ko-KR/tools.json b/src/locales/ko-KR/tools.json
index 32e47affd..3e9421be6 100644
--- a/src/locales/ko-KR/tools.json
+++ b/src/locales/ko-KR/tools.json
@@ -14,5 +14,200 @@
"local_backup": "로컬 백업",
"cloud_backup": "클라우드 백업",
"auto_backup": "자동 백업",
- "data_migration": "데이터 마이그레이션"
+ "data_migration": "데이터 마이그레이션",
+ "network_rules_title": "네트워크 규칙",
+ "network_rules_back": "도구로 돌아가기",
+ "network_rules_master_switch": "전체 스위치",
+ "network_rules_search_placeholder": "규칙 이름 또는 도메인 검색",
+ "network_rules_filter_action": "동작",
+ "network_rules_filter_status": "상태",
+ "network_rules_filter_all_actions": "모든 동작",
+ "network_rules_filter_all_statuses": "모든 상태",
+ "network_rules_status_enabled": "사용 중",
+ "network_rules_status_disabled": "사용 안 함",
+ "network_rules_test_match": "일치 테스트",
+ "network_rules_new_rule": "새 규칙",
+ "network_rule_action_remove_response_headers": "응답 헤더 제거",
+ "network_rule_action_modify_request_headers": "요청 헤더 수정",
+ "network_rule_action_modify_response_headers": "응답 헤더 수정",
+ "network_rule_action_block": "차단",
+ "network_rule_action_redirect": "리디렉션",
+ "network_rule_action_allow": "허용",
+ "network_rules_column_name": "이름",
+ "network_rules_column_action": "동작",
+ "network_rules_column_scope": "적용 범위",
+ "network_rules_column_order": "순서",
+ "network_rules_select_all": "이 페이지의 규칙 선택",
+ "network_rules_bulk_actions": "일괄 작업",
+ "network_rules_selected_count": "{{count}}개 선택됨",
+ "network_rules_bulk_enable": "사용",
+ "network_rules_bulk_disable": "사용 안 함",
+ "network_rules_bulk_delete": "삭제",
+ "network_rules_clear_selection": "선택 해제",
+ "network_rules_select_rule": "{{name}} 선택",
+ "network_rules_drag_handle": "{{name}} 순서 변경",
+ "network_rules_drag_instructions": "스페이스바로 규칙을 집고 방향키로 이동한 뒤 스페이스바로 놓습니다. Esc 키를 누르면 취소됩니다.",
+ "network_rules_drag_picked_up": "「{{name}}」을(를) 집었습니다. 전체 {{total}}개 중 {{position}}번째입니다.",
+ "network_rules_drag_moved_over": "「{{name}}」이(가) {{position}}번째로 이동합니다.",
+ "network_rules_drag_dropped": "「{{name}}」을(를) {{position}}번째에 놓았습니다.",
+ "network_rules_drag_cancelled": "끌기를 취소했습니다. 「{{name}}」은(는) {{position}}번째에 그대로 있습니다.",
+ "network_rules_enable_rule": "{{name}} 사용",
+ "network_rules_row_menu": "추가 작업",
+ "network_rules_modified_at": "{{time}} 수정",
+ "network_rules_all_websites": "모든 웹사이트",
+ "network_rules_move_top": "맨 위로",
+ "network_rules_move_bottom": "맨 아래로",
+ "network_rules_move_to": "위치 지정 이동…",
+ "network_rules_move_to_title": "지정한 위치로 이동",
+ "network_rules_move_to_label": "위치 (1–{{total}})",
+ "network_rules_move_to_confirm": "이동",
+ "network_rules_cancel": "취소",
+ "network_rules_confirm": "계속",
+ "network_rules_filter_active": "필터 적용 중 · 전체 {{total}}개 중 {{matched}}개",
+ "network_rules_filter_drag_paused": "순서 변경에는 전체 목록이 필요하므로 드래그가 일시 중지되었습니다",
+ "network_rules_clear_filters": "필터 지우기",
+ "network_rules_empty_title": "아직 네트워크 규칙이 없습니다",
+ "network_rules_empty_description": "자주 쓰는 시나리오로 시작하거나 직접 만들어 보세요.",
+ "network_rules_no_results_title": "일치하는 규칙이 없습니다",
+ "network_rules_no_results_description": "다른 키워드를 사용하거나 필터를 지우세요.",
+ "network_rules_apply_error_title": "규칙을 브라우저에 적용하지 못했습니다",
+ "network_rules_apply_error_detail": "페이지는 아직 이전 버전의 규칙을 사용하고 있습니다.",
+ "network_rules_retry": "다시 시도",
+ "network_rules_load_error": "네트워크 규칙을 불러오지 못했습니다. 다시 시도하세요.",
+ "network_rules_unsupported_schema": "이 네트워크 규칙 데이터 형식은 아직 지원되지 않습니다.",
+ "network_rules_footer_total": "총 {{count}}개 · {{enabled}}개 사용 중",
+ "network_rules_quota_total": "동적 규칙 할당량 {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "헤더 수정 / 리디렉션 / 허용 {{used}}/{{limit}}",
+ "network_rules_prev_page": "이전 페이지",
+ "network_rules_next_page": "다음 페이지",
+ "network_rules_reorder_failed": "순서를 저장하지 못해 원래 순서로 되돌렸습니다.",
+ "network_rules_rule_saved": "규칙이 적용되었습니다",
+ "network_rules_rule_saved_apply_failed": "규칙은 저장했지만 브라우저 규칙을 업데이트하지 못했습니다.",
+ "network_rules_revision_conflict": "이 페이지가 최신 상태가 아닙니다. 최신 규칙을 불러온 뒤 다시 시도하세요.",
+ "network_rules_storage_error": "변경 사항을 저장하지 못했습니다.",
+ "network_rules_confirm_all_sites_title": "모든 웹사이트에 적용할까요?",
+ "network_rules_confirm_all_sites_description": "일치하는 모든 웹사이트의 기본 보호가 약해집니다. 계속할까요?",
+ "network_rule_template_csp": "CSP 제거",
+ "network_rule_template_csp_desc": "사이트 CSP에 막힌 스크립트를 실행되게 합니다.",
+ "network_rule_template_user_agent": "User-Agent 변경",
+ "network_rule_template_user_agent_desc": "다른 기기나 브라우저로 표시합니다.",
+ "network_rule_template_referer": "Referer 변경",
+ "network_rule_template_referer_desc": "참조 페이지 주소를 바꾸거나 제거합니다.",
+ "network_rule_template_response_headers": "응답 헤더 변경",
+ "network_rule_template_response_headers_desc": "CSP뿐 아니라 모든 응답 헤더를 추가·변경·삭제합니다.",
+ "network_rule_template_block": "요청 차단",
+ "network_rule_template_block_desc": "일치하는 요청이 전송되지 않게 합니다.",
+ "network_rule_template_redirect": "요청 리디렉션",
+ "network_rule_template_redirect_desc": "일치하는 요청을 다른 주소로 보냅니다.",
+ "network_rule_template_custom": "사용자 지정",
+ "network_rule_template_custom_desc": "조건과 동작을 직접 편집합니다. 요청 헤더 변경도 여기서 합니다.",
+ "network_rules_edit_title": "규칙 편집",
+ "network_rules_back_to_templates": "템플릿으로 돌아가기",
+ "network_rules_change_template": "유형 변경",
+ "network_rules_save": "저장",
+ "network_rules_edit": "편집",
+ "network_rules_delete": "삭제",
+ "network_rules_confirm_delete_title": "이 규칙을 삭제할까요?",
+ "network_rules_confirm_delete_description": "되돌릴 수 없습니다. 잠시만 쓰지 않는다면 대신 사용 중지하세요.",
+ "network_rules_confirm_delete_action": "규칙 삭제",
+ "network_rules_confirm_bulk_delete_title": "선택한 규칙 {{count}}개를 삭제할까요?",
+ "network_rules_rule_deleted": "규칙을 삭제했습니다.",
+ "network_rules_field_scope": "적용 사이트",
+ "network_rules_scope_help": "한 줄에 도메인 하나. 하위 도메인도 포함됩니다.",
+ "network_rules_all_sites_warning": "위험이 크므로 저장 전에 한 번 더 확인합니다.",
+ "network_rules_field_name": "규칙 이름",
+ "network_rules_optional": "(선택)",
+ "network_rules_field_removed_headers": "제거할 응답 헤더",
+ "network_rules_csp_include_xfo": "X-Frame-Options도 제거",
+ "network_rules_field_user_agent": "User-Agent 값",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer 처리",
+ "network_rules_referer_set": "지정한 주소로 설정",
+ "network_rules_referer_remove": "Referer 제거",
+ "network_rules_field_redirect_url": "리디렉션 대상",
+ "network_rules_field_action_type": "동작 유형",
+ "network_rules_field_request_headers": "요청 헤더 변경",
+ "network_rules_field_response_headers": "응답 헤더 변경",
+ "network_rules_header_name": "헤더 이름",
+ "network_rules_header_operation": "작업",
+ "network_rules_header_value": "값",
+ "network_rules_header_op_set": "설정",
+ "network_rules_header_op_append": "추가",
+ "network_rules_header_op_remove": "제거",
+ "network_rules_add_header": "헤더 추가",
+ "network_rules_remove_header": "이 헤더 삭제",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host, Origin은 변경할 수 없습니다.",
+ "network_rules_try_it": "URL 시험",
+ "network_rules_try_match_effect": "일치 · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "응답 헤더 {{count}}개를 제거합니다",
+ "network_rules_try_effect_modify_request_headers": "요청 헤더 {{count}}개를 변경합니다",
+ "network_rules_try_effect_modify_response_headers": "응답 헤더 {{count}}개를 변경합니다",
+ "network_rules_try_effect_block": "요청을 차단합니다",
+ "network_rules_try_effect_redirect": "{{url}}(으)로 리디렉션합니다",
+ "network_rules_try_effect_allow": "요청을 허용하고 이후 규칙을 건너뜁니다",
+ "network_rules_try_scope_only": "URL은 범위 안에 있지만, 「고급」에서 지정한 리소스 유형 또는 요청 메서드가 이번 메인 문서 GET 요청을 포함하지 않습니다.",
+ "network_rules_try_no_match": "이 규칙과 일치하지 않습니다",
+ "network_rules_try_invalid": "전체 http(s) 주소를 입력하세요.",
+ "network_rules_advanced": "고급",
+ "network_rules_field_resource_types": "리소스 유형",
+ "network_rules_field_request_methods": "요청 메서드",
+ "network_rules_field_excluded_domains": "제외할 도메인",
+ "network_rules_error_domain_required": "사이트를 하나 이상 입력하세요.",
+ "network_rules_error_domain_invalid": "올바른 HTTP(S) 호스트 이름을 입력하세요.",
+ "network_rules_error_domain_credentials": "URL에서 사용자 이름과 비밀번호를 지우세요.",
+ "network_rules_error_domain_wildcard": "example.com 형식으로 입력하세요. 하위 도메인은 자동 포함됩니다.",
+ "network_rules_error_domain_single_label": "example.com처럼 점이 있는 도메인을 입력하세요.",
+ "network_rules_error_domain_too_long": "이 도메인은 너무 깁니다.",
+ "network_rules_error_domain_count_invalid": "규칙당 도메인은 1~100개까지 사용하세요.",
+ "network_rules_error_header_name_invalid": "올바른 헤더 이름을 입력하세요.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host, Origin은 호출자의 신원을 노출하거나 위조할 수 있어 변경할 수 없습니다.",
+ "network_rules_error_header_value_required": "이 헤더의 값을 입력하세요.",
+ "network_rules_error_redirect_url_invalid": "http(s)로 시작하는 전체 주소를 입력하세요.",
+ "network_rules_card_description": "일치하는 요청에 대한 브라우저 처리를 변경합니다: CSP 제거, 헤더 재작성, 차단, 리디렉션.",
+ "network_rules_state_active": "적용됨",
+ "network_rules_state_paused": "일시 중지됨",
+ "network_rules_state_failed": "적용 안 됨",
+ "network_rules_summary_counts": "{{enabled}}개 활성화 · 총 {{total}}개",
+ "network_rules_preview_more": "외 {{count}}개…",
+ "network_rules_manage": "규칙 관리",
+ "network_rules_paused_title": "모든 규칙이 일시 중지됨",
+ "network_rules_paused_description": "규칙은 유지되며 마스터 스위치를 다시 켜면 바로 복원됩니다.",
+ "network_rules_reload_hint": "이미 열린 페이지는 새로 고쳐야 적용됩니다.",
+ "network_rules_reload_tabs": "탭 새로 고침",
+ "network_rules_match_description": "URL을 입력하면 현재 활성화된 규칙이 어떻게 처리하는지 확인할 수 있습니다.",
+ "network_rules_match_paused": "마스터 스위치가 꺼져 있어 규칙이 현재 적용되지 않습니다. 아래는 다시 켰을 때의 시뮬레이션 결과입니다.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "최종 결과",
+ "network_rules_match_hits": "일치한 규칙(목록 순서)",
+ "network_rules_match_note": "현재 규칙을 기반으로 한 시뮬레이션이며 브라우저의 실제 일치 기록은 포함하지 않습니다.",
+ "network_rules_match_close": "닫기",
+ "network_rules_outcome_none": "일치 없음",
+ "network_rules_outcome_blocked": "차단됨",
+ "network_rules_outcome_redirected": "리디렉션됨",
+ "network_rules_outcome_allowed": "허용됨",
+ "network_rules_outcome_modified": "헤더 수정됨",
+ "network_rules_outcome_blocked_detail": "요청이 차단됩니다. 차단은 요청 전체를 중단시키므로 다른 일치 규칙은 실행되지 않습니다.",
+ "network_rules_outcome_redirected_detail": "요청이 {{url}}(으)로 리디렉션됩니다.",
+ "network_rules_outcome_allowed_detail": "요청이 허용되며 목록에서 더 아래에 있는 규칙은 실행되지 않습니다.",
+ "network_rules_outcome_modified_detail": "{{count}}개 규칙이 목록 순서대로 요청 또는 응답 헤더를 수정합니다.",
+ "network_rules_outcome_none_detail": "활성화된 규칙 중 이 요청과 일치하는 것이 없습니다.",
+ "network_rules_hit_applied": "실행됨",
+ "network_rules_hit_blocked": "요청이 #{{position}}에서 차단되어 실행되지 않습니다",
+ "network_rules_hit_overridden": "앞선 #{{position}}이(가) 이미 결과를 결정하여 실행되지 않습니다",
+ "network_rules_hit_allowed": "앞선 #{{position}}이(가) 요청을 허용하여 실행되지 않습니다",
+ "network_rule_resource_main_frame": "메인 프레임",
+ "network_rule_resource_sub_frame": "하위 프레임",
+ "network_rule_resource_stylesheet": "스타일시트",
+ "network_rule_resource_script": "스크립트",
+ "network_rule_resource_image": "이미지",
+ "network_rule_resource_font": "글꼴",
+ "network_rule_resource_object": "오브젝트",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP 보고서",
+ "network_rule_resource_media": "미디어",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "기타"
}
diff --git a/src/locales/pt-BR/tools.json b/src/locales/pt-BR/tools.json
index cc927b549..2ca221f1b 100644
--- a/src/locales/pt-BR/tools.json
+++ b/src/locales/pt-BR/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Backup local",
"cloud_backup": "Backup na nuvem",
"auto_backup": "Backup automático",
- "data_migration": "Migração de dados"
-}
\ No newline at end of file
+ "data_migration": "Migração de dados",
+ "network_rules_title": "Regras de rede",
+ "network_rules_back": "Voltar para ferramentas",
+ "network_rules_master_switch": "Chave geral",
+ "network_rules_search_placeholder": "Pesquisar nomes de regras ou domínios",
+ "network_rules_filter_action": "Ação",
+ "network_rules_filter_status": "Status",
+ "network_rules_filter_all_actions": "Todas as ações",
+ "network_rules_filter_all_statuses": "Todos os status",
+ "network_rules_status_enabled": "Ativada",
+ "network_rules_status_disabled": "Desativada",
+ "network_rules_test_match": "Testar correspondência",
+ "network_rules_new_rule": "Nova regra",
+ "network_rule_action_remove_response_headers": "Remover cabeçalhos de resposta",
+ "network_rule_action_modify_request_headers": "Alterar cabeçalhos de requisição",
+ "network_rule_action_modify_response_headers": "Alterar cabeçalhos de resposta",
+ "network_rule_action_block": "Bloquear",
+ "network_rule_action_redirect": "Redirecionar",
+ "network_rule_action_allow": "Permitir",
+ "network_rules_column_name": "Nome",
+ "network_rules_column_action": "Ação",
+ "network_rules_column_scope": "Escopo",
+ "network_rules_column_order": "Ordem",
+ "network_rules_select_all": "Selecionar as regras desta página",
+ "network_rules_bulk_actions": "Ações em massa",
+ "network_rules_selected_count": "{{count}} selecionadas",
+ "network_rules_bulk_enable": "Ativar",
+ "network_rules_bulk_disable": "Desativar",
+ "network_rules_bulk_delete": "Excluir",
+ "network_rules_clear_selection": "Limpar seleção",
+ "network_rules_select_rule": "Selecionar {{name}}",
+ "network_rules_drag_handle": "Reordenar {{name}}",
+ "network_rules_drag_instructions": "Pressione espaço para pegar a regra, use as setas para movê-la, pressione espaço novamente para soltar ou Esc para cancelar.",
+ "network_rules_drag_picked_up": "“{{name}}” foi pega, posição {{position}} de {{total}}.",
+ "network_rules_drag_moved_over": "“{{name}}” será movida para a posição {{position}}.",
+ "network_rules_drag_dropped": "“{{name}}” foi solta na posição {{position}}.",
+ "network_rules_drag_cancelled": "Arrasto cancelado, “{{name}}” permanece na posição {{position}}.",
+ "network_rules_enable_rule": "Ativar {{name}}",
+ "network_rules_row_menu": "Mais ações",
+ "network_rules_modified_at": "Modificada {{time}}",
+ "network_rules_all_websites": "Todos os sites",
+ "network_rules_move_top": "Mover para o topo",
+ "network_rules_move_bottom": "Mover para o fim",
+ "network_rules_move_to": "Mover para…",
+ "network_rules_move_to_title": "Mover para a posição",
+ "network_rules_move_to_label": "Posição (1–{{total}})",
+ "network_rules_move_to_confirm": "Mover",
+ "network_rules_cancel": "Cancelar",
+ "network_rules_confirm": "Continuar",
+ "network_rules_filter_active": "Filtrado · {{matched}} de {{total}} regras",
+ "network_rules_filter_drag_paused": "Reordenar exige a lista completa, então o arrasto está pausado",
+ "network_rules_clear_filters": "Limpar filtros",
+ "network_rules_empty_title": "Ainda não há regras de rede",
+ "network_rules_empty_description": "Comece por um cenário comum ou crie a sua própria regra.",
+ "network_rules_no_results_title": "Nenhuma regra correspondente",
+ "network_rules_no_results_description": "Tente outra palavra-chave ou limpe os filtros.",
+ "network_rules_apply_error_title": "Não foi possível aplicar as regras ao navegador",
+ "network_rules_apply_error_detail": "As páginas ainda usam a versão anterior das regras.",
+ "network_rules_retry": "Tentar novamente",
+ "network_rules_load_error": "Não foi possível carregar as regras de rede. Tente novamente.",
+ "network_rules_unsupported_schema": "Este formato de dados de regras de rede ainda não é suportado.",
+ "network_rules_footer_total": "{{count}} regras · {{enabled}} ativadas",
+ "network_rules_quota_total": "Cota de regras dinâmicas {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Reescrita de cabeçalho / redirecionamento / permissão {{used}}/{{limit}}",
+ "network_rules_prev_page": "Página anterior",
+ "network_rules_next_page": "Próxima página",
+ "network_rules_reorder_failed": "Não foi possível salvar a ordem; ela foi restaurada.",
+ "network_rules_rule_saved": "Regra aplicada",
+ "network_rules_rule_saved_apply_failed": "Regra salva, mas não foi possível atualizar as regras do navegador.",
+ "network_rules_revision_conflict": "Esta página está desatualizada. Recarregue as regras mais recentes e tente de novo.",
+ "network_rules_storage_error": "Não foi possível salvar a alteração.",
+ "network_rules_confirm_all_sites_title": "Afetar todos os sites?",
+ "network_rules_confirm_all_sites_description": "Isso enfraquece a proteção integrada de todos os sites correspondentes. Continuar?",
+ "network_rule_template_csp": "Remover CSP",
+ "network_rule_template_csp_desc": "Permite executar scripts bloqueados pela CSP do site.",
+ "network_rule_template_user_agent": "Alterar User-Agent",
+ "network_rule_template_user_agent_desc": "Apresenta outro dispositivo ou navegador.",
+ "network_rule_template_referer": "Alterar Referer",
+ "network_rule_template_referer_desc": "Reescreve ou remove o endereço da página de origem.",
+ "network_rule_template_response_headers": "Alterar cabeçalhos de resposta",
+ "network_rule_template_response_headers_desc": "Adiciona, altera ou remove qualquer cabeçalho de resposta, não só CSP.",
+ "network_rule_template_block": "Bloquear solicitações",
+ "network_rule_template_block_desc": "Impede o envio das solicitações correspondentes.",
+ "network_rule_template_redirect": "Redirecionar solicitações",
+ "network_rule_template_redirect_desc": "Envia as solicitações correspondentes para outro endereço.",
+ "network_rule_template_custom": "Personalizado",
+ "network_rule_template_custom_desc": "Edite a condição e a ação diretamente, incluindo cabeçalhos de solicitação.",
+ "network_rules_edit_title": "Editar regra",
+ "network_rules_back_to_templates": "Voltar aos modelos",
+ "network_rules_change_template": "Trocar tipo",
+ "network_rules_save": "Salvar",
+ "network_rules_edit": "Editar",
+ "network_rules_delete": "Excluir",
+ "network_rules_confirm_delete_title": "Excluir esta regra?",
+ "network_rules_confirm_delete_description": "Não é possível desfazer. Se for só uma pausa, desative a regra.",
+ "network_rules_confirm_delete_action": "Excluir regra",
+ "network_rules_confirm_bulk_delete_title": "Excluir {{count}} regras selecionadas?",
+ "network_rules_rule_deleted": "Regra excluída.",
+ "network_rules_field_scope": "Sites",
+ "network_rules_scope_help": "Um domínio por linha; subdomínios são incluídos.",
+ "network_rules_all_sites_warning": "Risco maior; será pedida confirmação antes de salvar.",
+ "network_rules_field_name": "Nome da regra",
+ "network_rules_optional": "(opcional)",
+ "network_rules_field_removed_headers": "Cabeçalhos de resposta a remover",
+ "network_rules_csp_include_xfo": "Remover também X-Frame-Options",
+ "network_rules_field_user_agent": "Valor do User-Agent",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Tratamento do Referer",
+ "network_rules_referer_set": "Definir um endereço fixo",
+ "network_rules_referer_remove": "Remover o Referer",
+ "network_rules_field_redirect_url": "Redirecionar para",
+ "network_rules_field_action_type": "Tipo de ação",
+ "network_rules_field_request_headers": "Alterações de cabeçalho de solicitação",
+ "network_rules_field_response_headers": "Alterações de cabeçalho de resposta",
+ "network_rules_header_name": "Nome do cabeçalho",
+ "network_rules_header_operation": "Operação",
+ "network_rules_header_value": "Valor",
+ "network_rules_header_op_set": "Definir",
+ "network_rules_header_op_append": "Anexar",
+ "network_rules_header_op_remove": "Remover",
+ "network_rules_add_header": "Adicionar cabeçalho",
+ "network_rules_remove_header": "Remover este cabeçalho",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host e Origin não podem ser modificados.",
+ "network_rules_try_it": "Testar uma URL",
+ "network_rules_try_match_effect": "Corresponde · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "remove {{count}} cabeçalho(s) de resposta",
+ "network_rules_try_effect_modify_request_headers": "reescreve {{count}} cabeçalho(s) de requisição",
+ "network_rules_try_effect_modify_response_headers": "reescreve {{count}} cabeçalho(s) de resposta",
+ "network_rules_try_effect_block": "bloqueia a requisição",
+ "network_rules_try_effect_redirect": "redireciona para {{url}}",
+ "network_rules_try_effect_allow": "libera a requisição e ignora as regras seguintes",
+ "network_rules_try_scope_only": "A URL está no escopo, mas os tipos de recurso ou métodos de requisição definidos em Avançado não cobrem esta requisição GET do documento principal.",
+ "network_rules_try_no_match": "Não corresponde a esta regra",
+ "network_rules_try_invalid": "Informe um endereço http(s) completo.",
+ "network_rules_advanced": "Avançado",
+ "network_rules_field_resource_types": "Tipos de recurso",
+ "network_rules_field_request_methods": "Métodos de solicitação",
+ "network_rules_field_excluded_domains": "Domínios excluídos",
+ "network_rules_error_domain_required": "Informe pelo menos um site.",
+ "network_rules_error_domain_invalid": "Informe um nome de host HTTP(S) válido.",
+ "network_rules_error_domain_credentials": "Remova o usuário e a senha da URL.",
+ "network_rules_error_domain_wildcard": "Informe example.com; subdomínios são incluídos automaticamente.",
+ "network_rules_error_domain_single_label": "Informe um domínio com ponto, como example.com.",
+ "network_rules_error_domain_too_long": "Este domínio é longo demais.",
+ "network_rules_error_domain_count_invalid": "Use de 1 a 100 domínios por regra.",
+ "network_rules_error_header_name_invalid": "Informe um nome de cabeçalho válido.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host e Origin expõem ou falsificam a identidade de quem chama, por isso não podem ser alterados.",
+ "network_rules_error_header_value_required": "Informe um valor para este cabeçalho.",
+ "network_rules_error_redirect_url_invalid": "Informe um endereço completo iniciado por http(s).",
+ "network_rules_card_description": "Altere como o navegador trata as solicitações correspondentes: remover CSP, reescrever cabeçalhos, bloquear ou redirecionar.",
+ "network_rules_state_active": "Ativo",
+ "network_rules_state_paused": "Pausado",
+ "network_rules_state_failed": "Não aplicado",
+ "network_rules_summary_counts": "{{enabled}} ativas · {{total}} regras no total",
+ "network_rules_preview_more": "mais {{count}}…",
+ "network_rules_manage": "Gerenciar regras",
+ "network_rules_paused_title": "Todas as regras estão pausadas",
+ "network_rules_paused_description": "As regras são mantidas e voltam a valer assim que a chave principal for reativada.",
+ "network_rules_reload_hint": "As páginas já abertas precisam ser recarregadas.",
+ "network_rules_reload_tabs": "Recarregar abas",
+ "network_rules_match_description": "Digite uma URL para ver como as regras ativas a tratariam.",
+ "network_rules_match_paused": "O interruptor principal está desligado, então estas regras não estão em vigor; os resultados abaixo simulam religá-lo.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Resultado final",
+ "network_rules_match_hits": "Regras correspondentes (na ordem da lista)",
+ "network_rules_match_note": "Simulação com base nas regras atuais; não inclui os registros reais de correspondência do navegador.",
+ "network_rules_match_close": "Fechar",
+ "network_rules_outcome_none": "Sem correspondência",
+ "network_rules_outcome_blocked": "Bloqueado",
+ "network_rules_outcome_redirected": "Redirecionado",
+ "network_rules_outcome_allowed": "Permitido",
+ "network_rules_outcome_modified": "Cabeçalhos modificados",
+ "network_rules_outcome_blocked_detail": "A solicitação é bloqueada. O bloqueio interrompe a solicitação inteira, então nenhuma outra regra correspondente é executada.",
+ "network_rules_outcome_redirected_detail": "A solicitação é redirecionada para {{url}}.",
+ "network_rules_outcome_allowed_detail": "A solicitação é permitida; as regras abaixo na lista não são executadas.",
+ "network_rules_outcome_modified_detail": "{{count}} regra(s) reescrevem cabeçalhos de solicitação ou resposta, na ordem da lista.",
+ "network_rules_outcome_none_detail": "Nenhuma das regras ativas corresponde a esta solicitação.",
+ "network_rules_hit_applied": "É executada",
+ "network_rules_hit_blocked": "A solicitação é bloqueada por #{{position}}, então esta regra não é executada",
+ "network_rules_hit_overridden": "#{{position}} vem antes e já definiu o resultado, então esta regra não é executada",
+ "network_rules_hit_allowed": "#{{position}} vem antes e permite a solicitação, então esta regra não é executada",
+ "network_rule_resource_main_frame": "Quadro principal",
+ "network_rule_resource_sub_frame": "Subquadro",
+ "network_rule_resource_stylesheet": "Folha de estilo",
+ "network_rule_resource_script": "Script",
+ "network_rule_resource_image": "Imagem",
+ "network_rule_resource_font": "Fonte",
+ "network_rule_resource_object": "Objeto",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "Relatório CSP",
+ "network_rule_resource_media": "Mídia",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Outro"
+}
diff --git a/src/locales/ru-RU/tools.json b/src/locales/ru-RU/tools.json
index 673d4ec2f..400049006 100644
--- a/src/locales/ru-RU/tools.json
+++ b/src/locales/ru-RU/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Локальная резервная копия",
"cloud_backup": "Облачная резервная копия",
"auto_backup": "Автоматическая резервная копия",
- "data_migration": "Миграция данных"
+ "data_migration": "Миграция данных",
+ "network_rules_title": "Сетевые правила",
+ "network_rules_back": "Назад к инструментам",
+ "network_rules_master_switch": "Общий переключатель",
+ "network_rules_search_placeholder": "Поиск по названию правила или домену",
+ "network_rules_filter_action": "Действие",
+ "network_rules_filter_status": "Статус",
+ "network_rules_filter_all_actions": "Все действия",
+ "network_rules_filter_all_statuses": "Все статусы",
+ "network_rules_status_enabled": "Включено",
+ "network_rules_status_disabled": "Отключено",
+ "network_rules_test_match": "Проверить совпадение",
+ "network_rules_new_rule": "Новое правило",
+ "network_rule_action_remove_response_headers": "Удалять заголовки ответа",
+ "network_rule_action_modify_request_headers": "Менять заголовки запроса",
+ "network_rule_action_modify_response_headers": "Менять заголовки ответа",
+ "network_rule_action_block": "Блокировать",
+ "network_rule_action_redirect": "Перенаправлять",
+ "network_rule_action_allow": "Разрешать",
+ "network_rules_column_name": "Название",
+ "network_rules_column_action": "Действие",
+ "network_rules_column_scope": "Область действия",
+ "network_rules_column_order": "Порядок",
+ "network_rules_select_all": "Выбрать правила на этой странице",
+ "network_rules_bulk_actions": "Массовые действия",
+ "network_rules_selected_count": "Выбрано: {{count}}",
+ "network_rules_bulk_enable": "Включить",
+ "network_rules_bulk_disable": "Отключить",
+ "network_rules_bulk_delete": "Удалить",
+ "network_rules_clear_selection": "Снять выделение",
+ "network_rules_select_rule": "Выбрать {{name}}",
+ "network_rules_drag_handle": "Изменить порядок {{name}}",
+ "network_rules_drag_instructions": "Нажмите пробел, чтобы взять правило, стрелками переместите его, снова нажмите пробел, чтобы отпустить, или Esc для отмены.",
+ "network_rules_drag_picked_up": "Правило «{{name}}» взято, позиция {{position}} из {{total}}.",
+ "network_rules_drag_moved_over": "«{{name}}» переместится на позицию {{position}}.",
+ "network_rules_drag_dropped": "«{{name}}» помещено на позицию {{position}}.",
+ "network_rules_drag_cancelled": "Перетаскивание отменено, «{{name}}» осталось на позиции {{position}}.",
+ "network_rules_enable_rule": "Включить {{name}}",
+ "network_rules_row_menu": "Другие действия",
+ "network_rules_modified_at": "Изменено {{time}}",
+ "network_rules_all_websites": "Все сайты",
+ "network_rules_move_top": "В начало",
+ "network_rules_move_bottom": "В конец",
+ "network_rules_move_to": "Переместить…",
+ "network_rules_move_to_title": "Переместить на позицию",
+ "network_rules_move_to_label": "Позиция (1–{{total}})",
+ "network_rules_move_to_confirm": "Переместить",
+ "network_rules_cancel": "Отмена",
+ "network_rules_confirm": "Продолжить",
+ "network_rules_filter_active": "Фильтр · {{matched}} из {{total}} правил",
+ "network_rules_filter_drag_paused": "Для изменения порядка нужен полный список, перетаскивание приостановлено",
+ "network_rules_clear_filters": "Сбросить фильтры",
+ "network_rules_empty_title": "Сетевых правил пока нет",
+ "network_rules_empty_description": "Начните с частого сценария или создайте правило сами.",
+ "network_rules_no_results_title": "Подходящих правил нет",
+ "network_rules_no_results_description": "Попробуйте другое слово или сбросьте фильтры.",
+ "network_rules_apply_error_title": "Не удалось применить правила в браузере",
+ "network_rules_apply_error_detail": "Страницы всё ещё используют предыдущую версию правил.",
+ "network_rules_retry": "Повторить",
+ "network_rules_load_error": "Не удалось загрузить сетевые правила. Повторите попытку.",
+ "network_rules_unsupported_schema": "Этот формат данных сетевых правил пока не поддерживается.",
+ "network_rules_footer_total": "Всего правил: {{count}} · включено: {{enabled}}",
+ "network_rules_quota_total": "Квота динамических правил {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Изменение заголовков / перенаправление / разрешение {{used}}/{{limit}}",
+ "network_rules_prev_page": "Предыдущая страница",
+ "network_rules_next_page": "Следующая страница",
+ "network_rules_reorder_failed": "Не удалось сохранить порядок, он восстановлен.",
+ "network_rules_rule_saved": "Правило применено",
+ "network_rules_rule_saved_apply_failed": "Правило сохранено, но обновить правила браузера не удалось.",
+ "network_rules_revision_conflict": "Страница устарела. Загрузите актуальные правила и повторите попытку.",
+ "network_rules_storage_error": "Не удалось сохранить изменение.",
+ "network_rules_confirm_all_sites_title": "Затронуть все сайты?",
+ "network_rules_confirm_all_sites_description": "Это ослабит встроенную защиту всех подходящих сайтов. Продолжить?",
+ "network_rule_template_csp": "Удалить CSP",
+ "network_rule_template_csp_desc": "Позволяет работать скриптам, которые блокирует CSP сайта.",
+ "network_rule_template_user_agent": "Изменить User-Agent",
+ "network_rule_template_user_agent_desc": "Выдаёт другое устройство или браузер.",
+ "network_rule_template_referer": "Изменить Referer",
+ "network_rule_template_referer_desc": "Меняет или удаляет адрес страницы-источника.",
+ "network_rule_template_response_headers": "Изменить заголовки ответа",
+ "network_rule_template_response_headers_desc": "Добавляет, меняет или удаляет любые заголовки ответа, не только CSP.",
+ "network_rule_template_block": "Блокировать запросы",
+ "network_rule_template_block_desc": "Не даёт отправлять подходящие запросы.",
+ "network_rule_template_redirect": "Перенаправлять запросы",
+ "network_rule_template_redirect_desc": "Отправляет подходящие запросы на другой адрес.",
+ "network_rule_template_custom": "Свой вариант",
+ "network_rule_template_custom_desc": "Задайте условие и действие вручную, включая изменение заголовков запроса.",
+ "network_rules_edit_title": "Изменить правило",
+ "network_rules_back_to_templates": "Назад к шаблонам",
+ "network_rules_change_template": "Сменить тип",
+ "network_rules_save": "Сохранить",
+ "network_rules_edit": "Изменить",
+ "network_rules_delete": "Удалить",
+ "network_rules_confirm_delete_title": "Удалить это правило?",
+ "network_rules_confirm_delete_description": "Отменить будет нельзя. Если правило нужно лишь приостановить, отключите его.",
+ "network_rules_confirm_delete_action": "Удалить правило",
+ "network_rules_confirm_bulk_delete_title": "Удалить выбранные правила ({{count}})?",
+ "network_rules_rule_deleted": "Правило удалено.",
+ "network_rules_field_scope": "Сайты",
+ "network_rules_scope_help": "По одному домену в строке; поддомены включаются.",
+ "network_rules_all_sites_warning": "Повышенный риск; перед сохранением потребуется подтверждение.",
+ "network_rules_field_name": "Название правила",
+ "network_rules_optional": "(необязательно)",
+ "network_rules_field_removed_headers": "Удаляемые заголовки ответа",
+ "network_rules_csp_include_xfo": "Также удалять X-Frame-Options",
+ "network_rules_field_user_agent": "Значение User-Agent",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Обработка Referer",
+ "network_rules_referer_set": "Задать фиксированный адрес",
+ "network_rules_referer_remove": "Удалить Referer",
+ "network_rules_field_redirect_url": "Перенаправлять на",
+ "network_rules_field_action_type": "Тип действия",
+ "network_rules_field_request_headers": "Изменения заголовков запроса",
+ "network_rules_field_response_headers": "Изменения заголовков ответа",
+ "network_rules_header_name": "Имя заголовка",
+ "network_rules_header_operation": "Операция",
+ "network_rules_header_value": "Значение",
+ "network_rules_header_op_set": "Задать",
+ "network_rules_header_op_append": "Добавить",
+ "network_rules_header_op_remove": "Удалить",
+ "network_rules_add_header": "Добавить заголовок",
+ "network_rules_remove_header": "Удалить этот заголовок",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host и Origin изменять нельзя.",
+ "network_rules_try_it": "Проверить URL",
+ "network_rules_try_match_effect": "Подходит · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "удалит заголовков ответа: {{count}}",
+ "network_rules_try_effect_modify_request_headers": "изменит заголовков запроса: {{count}}",
+ "network_rules_try_effect_modify_response_headers": "изменит заголовков ответа: {{count}}",
+ "network_rules_try_effect_block": "заблокирует запрос",
+ "network_rules_try_effect_redirect": "перенаправит на {{url}}",
+ "network_rules_try_effect_allow": "пропустит запрос и не применит последующие правила",
+ "network_rules_try_scope_only": "URL входит в область правила, но выбранные в разделе «Дополнительно» типы ресурсов или методы запроса не охватывают этот GET-запрос основного документа.",
+ "network_rules_try_no_match": "Не подходит под правило",
+ "network_rules_try_invalid": "Введите полный адрес http(s).",
+ "network_rules_advanced": "Дополнительно",
+ "network_rules_field_resource_types": "Типы ресурсов",
+ "network_rules_field_request_methods": "Методы запроса",
+ "network_rules_field_excluded_domains": "Исключённые домены",
+ "network_rules_error_domain_required": "Укажите хотя бы один сайт.",
+ "network_rules_error_domain_invalid": "Введите корректное имя узла HTTP(S).",
+ "network_rules_error_domain_credentials": "Уберите имя пользователя и пароль из URL.",
+ "network_rules_error_domain_wildcard": "Введите example.com — поддомены учитываются автоматически.",
+ "network_rules_error_domain_single_label": "Введите домен с точкой, например example.com.",
+ "network_rules_error_domain_too_long": "Этот домен слишком длинный.",
+ "network_rules_error_domain_count_invalid": "Используйте от 1 до 100 доменов в правиле.",
+ "network_rules_error_header_name_invalid": "Введите корректное имя заголовка.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host и Origin раскрывают или подделывают личность вызывающей стороны, поэтому их нельзя изменять.",
+ "network_rules_error_header_value_required": "Укажите значение этого заголовка.",
+ "network_rules_error_redirect_url_invalid": "Введите полный адрес, начинающийся с http(s).",
+ "network_rules_card_description": "Измените обработку соответствующих запросов браузером: удаление CSP, изменение заголовков, блокировка или перенаправление.",
+ "network_rules_state_active": "Активно",
+ "network_rules_state_paused": "Приостановлено",
+ "network_rules_state_failed": "Не применено",
+ "network_rules_summary_counts": "Активно: {{enabled}} · всего правил: {{total}}",
+ "network_rules_preview_more": "ещё {{count}}…",
+ "network_rules_manage": "Управление правилами",
+ "network_rules_paused_title": "Все правила приостановлены",
+ "network_rules_paused_description": "Правила сохраняются и снова заработают, как только главный переключатель будет включён.",
+ "network_rules_reload_hint": "Уже открытые страницы нужно перезагрузить.",
+ "network_rules_reload_tabs": "Обновить вкладки",
+ "network_rules_match_description": "Введите URL, чтобы увидеть, как его обработают включённые правила.",
+ "network_rules_match_paused": "Главный переключатель выключен, эти правила сейчас не действуют; ниже — симуляция того, что будет после включения.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Итоговый результат",
+ "network_rules_match_hits": "Совпавшие правила (в порядке списка)",
+ "network_rules_match_note": "Симуляция по текущим правилам; фактические срабатывания браузера не учитываются.",
+ "network_rules_match_close": "Закрыть",
+ "network_rules_outcome_none": "Нет совпадений",
+ "network_rules_outcome_blocked": "Заблокировано",
+ "network_rules_outcome_redirected": "Перенаправлено",
+ "network_rules_outcome_allowed": "Разрешено",
+ "network_rules_outcome_modified": "Заголовки изменены",
+ "network_rules_outcome_blocked_detail": "Запрос блокируется. Блокировка прерывает весь запрос, поэтому другие совпавшие правила не выполняются.",
+ "network_rules_outcome_redirected_detail": "Запрос перенаправляется на {{url}}.",
+ "network_rules_outcome_allowed_detail": "Запрос пропускается; правила ниже по списку не выполняются.",
+ "network_rules_outcome_modified_detail": "{{count}} правил(а) изменяют заголовки запроса или ответа в порядке списка.",
+ "network_rules_outcome_none_detail": "Ни одно из включённых правил не совпадает с этим запросом.",
+ "network_rules_hit_applied": "Выполняется",
+ "network_rules_hit_blocked": "Запрос заблокирован правилом #{{position}}, поэтому это правило не выполняется",
+ "network_rules_hit_overridden": "Правило #{{position}} стоит выше и уже определило результат, поэтому это правило не выполняется",
+ "network_rules_hit_allowed": "Правило #{{position}} стоит выше и пропускает запрос, поэтому это правило не выполняется",
+ "network_rule_resource_main_frame": "Основной фрейм",
+ "network_rule_resource_sub_frame": "Вложенный фрейм",
+ "network_rule_resource_stylesheet": "Таблица стилей",
+ "network_rule_resource_script": "Скрипт",
+ "network_rule_resource_image": "Изображение",
+ "network_rule_resource_font": "Шрифт",
+ "network_rule_resource_object": "Объект",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "Отчёт CSP",
+ "network_rule_resource_media": "Медиа",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Другое"
}
diff --git a/src/locales/tr-TR/tools.json b/src/locales/tr-TR/tools.json
index 33f1be76c..a5072f9f3 100644
--- a/src/locales/tr-TR/tools.json
+++ b/src/locales/tr-TR/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Yerel Yedekleme",
"cloud_backup": "Bulut Yedeklemesi",
"auto_backup": "Otomatik Yedekleme",
- "data_migration": "Veri Geçişi"
+ "data_migration": "Veri Geçişi",
+ "network_rules_title": "Ağ kuralları",
+ "network_rules_back": "Araçlara dön",
+ "network_rules_master_switch": "Ana anahtar",
+ "network_rules_search_placeholder": "Kural adı veya alan adı ara",
+ "network_rules_filter_action": "Eylem",
+ "network_rules_filter_status": "Durum",
+ "network_rules_filter_all_actions": "Tüm eylemler",
+ "network_rules_filter_all_statuses": "Tüm durumlar",
+ "network_rules_status_enabled": "Etkin",
+ "network_rules_status_disabled": "Devre dışı",
+ "network_rules_test_match": "Eşleşmeyi test et",
+ "network_rules_new_rule": "Yeni kural",
+ "network_rule_action_remove_response_headers": "Yanıt başlıklarını kaldır",
+ "network_rule_action_modify_request_headers": "İstek başlıklarını değiştir",
+ "network_rule_action_modify_response_headers": "Yanıt başlıklarını değiştir",
+ "network_rule_action_block": "Engelle",
+ "network_rule_action_redirect": "Yönlendir",
+ "network_rule_action_allow": "İzin ver",
+ "network_rules_column_name": "Ad",
+ "network_rules_column_action": "Eylem",
+ "network_rules_column_scope": "Kapsam",
+ "network_rules_column_order": "Sıra",
+ "network_rules_select_all": "Bu sayfadaki kuralları seç",
+ "network_rules_bulk_actions": "Toplu işlemler",
+ "network_rules_selected_count": "{{count}} seçildi",
+ "network_rules_bulk_enable": "Etkinleştir",
+ "network_rules_bulk_disable": "Devre dışı bırak",
+ "network_rules_bulk_delete": "Sil",
+ "network_rules_clear_selection": "Seçimi temizle",
+ "network_rules_select_rule": "{{name}} kuralını seç",
+ "network_rules_drag_handle": "{{name}} sırasını değiştir",
+ "network_rules_drag_instructions": "Kuralı almak için boşluk tuşuna basın, ok tuşlarıyla taşıyın, bırakmak için tekrar boşluk tuşuna, iptal için Esc tuşuna basın.",
+ "network_rules_drag_picked_up": "“{{name}}” alındı, {{total}} kural içinde {{position}}. sırada.",
+ "network_rules_drag_moved_over": "“{{name}}” {{position}}. sıraya taşınacak.",
+ "network_rules_drag_dropped": "“{{name}}” {{position}}. sıraya bırakıldı.",
+ "network_rules_drag_cancelled": "Sürükleme iptal edildi, “{{name}}” {{position}}. sırada kaldı.",
+ "network_rules_enable_rule": "{{name}} kuralını etkinleştir",
+ "network_rules_row_menu": "Diğer işlemler",
+ "network_rules_modified_at": "{{time}} değiştirildi",
+ "network_rules_all_websites": "Tüm siteler",
+ "network_rules_move_top": "En üste taşı",
+ "network_rules_move_bottom": "En alta taşı",
+ "network_rules_move_to": "Şuraya taşı…",
+ "network_rules_move_to_title": "Konuma taşı",
+ "network_rules_move_to_label": "Konum (1–{{total}})",
+ "network_rules_move_to_confirm": "Taşı",
+ "network_rules_cancel": "İptal",
+ "network_rules_confirm": "Devam et",
+ "network_rules_filter_active": "Filtrelendi · {{total}} kuraldan {{matched}} tanesi",
+ "network_rules_filter_drag_paused": "Sıralama tam listeyi gerektirir, sürükleme geçici olarak kapalı",
+ "network_rules_clear_filters": "Filtreleri temizle",
+ "network_rules_empty_title": "Henüz ağ kuralı yok",
+ "network_rules_empty_description": "Sık kullanılan bir senaryodan başlayın ya da kendiniz oluşturun.",
+ "network_rules_no_results_title": "Eşleşen kural yok",
+ "network_rules_no_results_description": "Başka bir anahtar sözcük deneyin veya filtreleri temizleyin.",
+ "network_rules_apply_error_title": "Kurallar tarayıcıya uygulanamadı",
+ "network_rules_apply_error_detail": "Sayfalar hâlâ kuralların önceki sürümünü kullanıyor.",
+ "network_rules_retry": "Yeniden dene",
+ "network_rules_load_error": "Ağ kuralları yüklenemedi. Lütfen yeniden deneyin.",
+ "network_rules_unsupported_schema": "Bu ağ kuralı veri biçimi henüz desteklenmiyor.",
+ "network_rules_footer_total": "{{count}} kural · {{enabled}} etkin",
+ "network_rules_quota_total": "Dinamik kural kotası {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Başlık değiştirme / yönlendirme / izin verme {{used}}/{{limit}}",
+ "network_rules_prev_page": "Önceki sayfa",
+ "network_rules_next_page": "Sonraki sayfa",
+ "network_rules_reorder_failed": "Sıra kaydedilemedi, eski sırasına geri alındı.",
+ "network_rules_rule_saved": "Kural uygulandı",
+ "network_rules_rule_saved_apply_failed": "Kural kaydedildi ancak tarayıcı kuralları güncellenemedi.",
+ "network_rules_revision_conflict": "Bu sayfa güncel değil. En son kuralları yükleyip yeniden deneyin.",
+ "network_rules_storage_error": "Değişiklik kaydedilemedi.",
+ "network_rules_confirm_all_sites_title": "Tüm siteler etkilensin mi?",
+ "network_rules_confirm_all_sites_description": "Bu, eşleşen tüm sitelerin yerleşik korumasını zayıflatır. Devam edilsin mi?",
+ "network_rule_template_csp": "CSP'yi kaldır",
+ "network_rule_template_csp_desc": "Sitenin CSP'si tarafından engellenen betiklerin çalışmasını sağlar.",
+ "network_rule_template_user_agent": "User-Agent'ı değiştir",
+ "network_rule_template_user_agent_desc": "Farklı bir cihaz veya tarayıcı olarak görünür.",
+ "network_rule_template_referer": "Referer'ı değiştir",
+ "network_rule_template_referer_desc": "Yönlendiren sayfa adresini değiştirir veya kaldırır.",
+ "network_rule_template_response_headers": "Yanıt başlıklarını değiştir",
+ "network_rule_template_response_headers_desc": "Yalnızca CSP değil, herhangi bir yanıt başlığını ekler, değiştirir veya kaldırır.",
+ "network_rule_template_block": "İstekleri engelle",
+ "network_rule_template_block_desc": "Eşleşen isteklerin gönderilmesini engeller.",
+ "network_rule_template_redirect": "İstekleri yönlendir",
+ "network_rule_template_redirect_desc": "Eşleşen istekleri başka bir adrese gönderir.",
+ "network_rule_template_custom": "Özel",
+ "network_rule_template_custom_desc": "Koşulu ve eylemi doğrudan düzenleyin; istek başlığı değişiklikleri dahil.",
+ "network_rules_edit_title": "Kuralı düzenle",
+ "network_rules_back_to_templates": "Şablonlara dön",
+ "network_rules_change_template": "Türü değiştir",
+ "network_rules_save": "Kaydet",
+ "network_rules_edit": "Düzenle",
+ "network_rules_delete": "Sil",
+ "network_rules_confirm_delete_title": "Bu kural silinsin mi?",
+ "network_rules_confirm_delete_description": "Bu geri alınamaz. Sadece ara vermek istiyorsan kuralı devre dışı bırak.",
+ "network_rules_confirm_delete_action": "Kuralı sil",
+ "network_rules_confirm_bulk_delete_title": "Seçili {{count}} kural silinsin mi?",
+ "network_rules_rule_deleted": "Kural silindi.",
+ "network_rules_field_scope": "Siteler",
+ "network_rules_scope_help": "Her satıra bir alan adı; alt alan adları dahildir.",
+ "network_rules_all_sites_warning": "Riski yüksektir; kaydetmeden önce onay istenir.",
+ "network_rules_field_name": "Kural adı",
+ "network_rules_optional": "(isteğe bağlı)",
+ "network_rules_field_removed_headers": "Kaldırılacak yanıt başlıkları",
+ "network_rules_csp_include_xfo": "X-Frame-Options da kaldırılsın",
+ "network_rules_field_user_agent": "User-Agent değeri",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer işlemi",
+ "network_rules_referer_set": "Sabit bir adrese ayarla",
+ "network_rules_referer_remove": "Referer'ı kaldır",
+ "network_rules_field_redirect_url": "Şuraya yönlendir",
+ "network_rules_field_action_type": "Eylem türü",
+ "network_rules_field_request_headers": "İstek başlığı değişiklikleri",
+ "network_rules_field_response_headers": "Yanıt başlığı değişiklikleri",
+ "network_rules_header_name": "Başlık adı",
+ "network_rules_header_operation": "İşlem",
+ "network_rules_header_value": "Değer",
+ "network_rules_header_op_set": "Ayarla",
+ "network_rules_header_op_append": "Ekle",
+ "network_rules_header_op_remove": "Kaldır",
+ "network_rules_add_header": "Başlık ekle",
+ "network_rules_remove_header": "Bu başlığı kaldır",
+ "network_rules_header_denied_note": "Cookie, Authorization, Host ve Origin değiştirilemez.",
+ "network_rules_try_it": "Bir URL dene",
+ "network_rules_try_match_effect": "Eşleşiyor · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "{{count}} yanıt başlığını kaldırır",
+ "network_rules_try_effect_modify_request_headers": "{{count}} istek başlığını değiştirir",
+ "network_rules_try_effect_modify_response_headers": "{{count}} yanıt başlığını değiştirir",
+ "network_rules_try_effect_block": "isteği engeller",
+ "network_rules_try_effect_redirect": "{{url}} adresine yönlendirir",
+ "network_rules_try_effect_allow": "isteğe izin verir ve sonraki kuralları atlar",
+ "network_rules_try_scope_only": "URL kapsam içinde, ancak Gelişmiş bölümünde seçilen kaynak türleri veya istek yöntemleri bu ana belge GET isteğini kapsamıyor.",
+ "network_rules_try_no_match": "Bu kuralla eşleşmiyor",
+ "network_rules_try_invalid": "Tam bir http(s) adresi gir.",
+ "network_rules_advanced": "Gelişmiş",
+ "network_rules_field_resource_types": "Kaynak türleri",
+ "network_rules_field_request_methods": "İstek yöntemleri",
+ "network_rules_field_excluded_domains": "Hariç tutulan alan adları",
+ "network_rules_error_domain_required": "En az bir site gir.",
+ "network_rules_error_domain_invalid": "Geçerli bir HTTP(S) ana bilgisayar adı gir.",
+ "network_rules_error_domain_credentials": "URL'den kullanıcı adını ve parolayı kaldır.",
+ "network_rules_error_domain_wildcard": "Bunun yerine example.com gir; alt alan adları otomatik dahildir.",
+ "network_rules_error_domain_single_label": "example.com gibi noktalı bir alan adı gir.",
+ "network_rules_error_domain_too_long": "Bu alan adı çok uzun.",
+ "network_rules_error_domain_count_invalid": "Kural başına 1 ile 100 arasında alan adı kullan.",
+ "network_rules_error_header_name_invalid": "Geçerli bir başlık adı gir.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host ve Origin çağıranın kimliğini sızdırır ya da taklit eder; bu yüzden değiştirilemez.",
+ "network_rules_error_header_value_required": "Bu başlık için bir değer gir.",
+ "network_rules_error_redirect_url_invalid": "http(s) ile başlayan tam bir adres gir.",
+ "network_rules_card_description": "Tarayıcının eşleşen istekleri nasıl işleyeceğini değiştirin: CSP kaldırma, üst bilgi değiştirme, engelleme veya yönlendirme.",
+ "network_rules_state_active": "Etkin",
+ "network_rules_state_paused": "Duraklatıldı",
+ "network_rules_state_failed": "Uygulanmadı",
+ "network_rules_summary_counts": "{{enabled}} etkin · toplam {{total}} kural",
+ "network_rules_preview_more": "{{count}} tane daha…",
+ "network_rules_manage": "Kuralları yönet",
+ "network_rules_paused_title": "Tüm kurallar duraklatıldı",
+ "network_rules_paused_description": "Kurallar korunur ve ana anahtar tekrar açıldığında hemen devreye girer.",
+ "network_rules_reload_hint": "Zaten açık olan sayfaların yenilenmesi gerekir.",
+ "network_rules_reload_tabs": "Sekmeleri yenile",
+ "network_rules_match_description": "Etkin kuralların bir adresi nasıl işleyeceğini görmek için URL girin.",
+ "network_rules_match_paused": "Ana anahtar kapalı olduğu için bu kurallar şu anda geçerli değil; aşağıdaki sonuçlar yeniden açıldığındaki durumu gösterir.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Nihai sonuç",
+ "network_rules_match_hits": "Eşleşen kurallar (liste sırasıyla)",
+ "network_rules_match_note": "Geçerli kurallara göre simülasyondur; tarayıcının gerçek eşleşme kayıtlarını içermez.",
+ "network_rules_match_close": "Kapat",
+ "network_rules_outcome_none": "Eşleşme yok",
+ "network_rules_outcome_blocked": "Engellendi",
+ "network_rules_outcome_redirected": "Yönlendirildi",
+ "network_rules_outcome_allowed": "İzin verildi",
+ "network_rules_outcome_modified": "Üst bilgiler değiştirildi",
+ "network_rules_outcome_blocked_detail": "İstek engellenir. Engelleme tüm isteği kesintiye uğratır, bu yüzden diğer eşleşen kurallar çalışmaz.",
+ "network_rules_outcome_redirected_detail": "İstek {{url}} adresine yönlendirilir.",
+ "network_rules_outcome_allowed_detail": "İsteğe izin verilir; listede daha aşağıdaki kurallar artık çalışmaz.",
+ "network_rules_outcome_modified_detail": "{{count}} kural, liste sırasıyla istek veya yanıt üst bilgilerini değiştirir.",
+ "network_rules_outcome_none_detail": "Etkin kuralların hiçbiri bu istekle eşleşmiyor.",
+ "network_rules_hit_applied": "Çalışır",
+ "network_rules_hit_blocked": "İstek #{{position}} tarafından engellendiği için bu kural çalışmaz",
+ "network_rules_hit_overridden": "#{{position}} daha üstte olduğu ve sonucu belirlediği için bu kural çalışmaz",
+ "network_rules_hit_allowed": "#{{position}} daha üstte olduğu ve isteğe izin verdiği için bu kural çalışmaz",
+ "network_rule_resource_main_frame": "Ana çerçeve",
+ "network_rule_resource_sub_frame": "Alt çerçeve",
+ "network_rule_resource_stylesheet": "Stil sayfası",
+ "network_rule_resource_script": "Betik",
+ "network_rule_resource_image": "Görsel",
+ "network_rule_resource_font": "Yazı tipi",
+ "network_rule_resource_object": "Nesne",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP raporu",
+ "network_rule_resource_media": "Medya",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Diğer"
}
diff --git a/src/locales/vi-VN/tools.json b/src/locales/vi-VN/tools.json
index 131097b71..53d411b73 100644
--- a/src/locales/vi-VN/tools.json
+++ b/src/locales/vi-VN/tools.json
@@ -14,5 +14,200 @@
"local_backup": "Sao lưu cục bộ",
"cloud_backup": "Sao lưu đám mây",
"auto_backup": "Sao lưu tự động",
- "data_migration": "Di chuyển dữ liệu"
+ "data_migration": "Di chuyển dữ liệu",
+ "network_rules_title": "Quy tắc mạng",
+ "network_rules_back": "Quay lại công cụ",
+ "network_rules_master_switch": "Công tắc chính",
+ "network_rules_search_placeholder": "Tìm theo tên quy tắc hoặc tên miền",
+ "network_rules_filter_action": "Hành động",
+ "network_rules_filter_status": "Trạng thái",
+ "network_rules_filter_all_actions": "Tất cả hành động",
+ "network_rules_filter_all_statuses": "Tất cả trạng thái",
+ "network_rules_status_enabled": "Đang bật",
+ "network_rules_status_disabled": "Đã tắt",
+ "network_rules_test_match": "Kiểm tra khớp",
+ "network_rules_new_rule": "Quy tắc mới",
+ "network_rule_action_remove_response_headers": "Xóa header phản hồi",
+ "network_rule_action_modify_request_headers": "Sửa header yêu cầu",
+ "network_rule_action_modify_response_headers": "Sửa header phản hồi",
+ "network_rule_action_block": "Chặn",
+ "network_rule_action_redirect": "Chuyển hướng",
+ "network_rule_action_allow": "Cho phép",
+ "network_rules_column_name": "Tên",
+ "network_rules_column_action": "Hành động",
+ "network_rules_column_scope": "Phạm vi",
+ "network_rules_column_order": "Thứ tự",
+ "network_rules_select_all": "Chọn các quy tắc trên trang này",
+ "network_rules_bulk_actions": "Thao tác hàng loạt",
+ "network_rules_selected_count": "Đã chọn {{count}}",
+ "network_rules_bulk_enable": "Bật",
+ "network_rules_bulk_disable": "Tắt",
+ "network_rules_bulk_delete": "Xóa",
+ "network_rules_clear_selection": "Bỏ chọn",
+ "network_rules_select_rule": "Chọn {{name}}",
+ "network_rules_drag_handle": "Đổi thứ tự {{name}}",
+ "network_rules_drag_instructions": "Nhấn phím cách để nhấc quy tắc, dùng phím mũi tên để di chuyển, nhấn phím cách lần nữa để thả hoặc Esc để hủy.",
+ "network_rules_drag_picked_up": "Đã nhấc “{{name}}”, vị trí {{position}} trên {{total}}.",
+ "network_rules_drag_moved_over": "“{{name}}” sẽ chuyển đến vị trí {{position}}.",
+ "network_rules_drag_dropped": "Đã thả “{{name}}” vào vị trí {{position}}.",
+ "network_rules_drag_cancelled": "Đã hủy kéo, “{{name}}” vẫn ở vị trí {{position}}.",
+ "network_rules_enable_rule": "Bật {{name}}",
+ "network_rules_row_menu": "Thao tác khác",
+ "network_rules_modified_at": "Sửa {{time}}",
+ "network_rules_all_websites": "Tất cả trang web",
+ "network_rules_move_top": "Lên đầu",
+ "network_rules_move_bottom": "Xuống cuối",
+ "network_rules_move_to": "Chuyển tới…",
+ "network_rules_move_to_title": "Chuyển tới vị trí",
+ "network_rules_move_to_label": "Vị trí (1–{{total}})",
+ "network_rules_move_to_confirm": "Chuyển",
+ "network_rules_cancel": "Hủy",
+ "network_rules_confirm": "Tiếp tục",
+ "network_rules_filter_active": "Đang lọc · {{matched}} trên {{total}} quy tắc",
+ "network_rules_filter_drag_paused": "Sắp xếp cần danh sách đầy đủ nên kéo thả tạm dừng",
+ "network_rules_clear_filters": "Xóa bộ lọc",
+ "network_rules_empty_title": "Chưa có quy tắc mạng",
+ "network_rules_empty_description": "Bắt đầu từ một tình huống thường gặp, hoặc tự tạo một quy tắc.",
+ "network_rules_no_results_title": "Không có quy tắc phù hợp",
+ "network_rules_no_results_description": "Hãy thử từ khóa khác hoặc xóa bộ lọc.",
+ "network_rules_apply_error_title": "Không thể áp dụng quy tắc vào trình duyệt",
+ "network_rules_apply_error_detail": "Các trang vẫn đang dùng phiên bản quy tắc trước đó.",
+ "network_rules_retry": "Thử lại",
+ "network_rules_load_error": "Không tải được quy tắc mạng, vui lòng thử lại.",
+ "network_rules_unsupported_schema": "Định dạng dữ liệu quy tắc mạng này chưa được hỗ trợ.",
+ "network_rules_footer_total": "{{count}} quy tắc · {{enabled}} đang bật",
+ "network_rules_quota_total": "Hạn mức quy tắc động {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "Sửa header / chuyển hướng / cho phép {{used}}/{{limit}}",
+ "network_rules_prev_page": "Trang trước",
+ "network_rules_next_page": "Trang sau",
+ "network_rules_reorder_failed": "Không lưu được thứ tự, đã khôi phục thứ tự cũ.",
+ "network_rules_rule_saved": "Đã áp dụng quy tắc",
+ "network_rules_rule_saved_apply_failed": "Đã lưu quy tắc nhưng không cập nhật được quy tắc của trình duyệt.",
+ "network_rules_revision_conflict": "Trang này không còn mới nhất. Hãy tải lại quy tắc mới nhất rồi thử lại.",
+ "network_rules_storage_error": "Không lưu được thay đổi.",
+ "network_rules_confirm_all_sites_title": "Ảnh hưởng mọi trang web?",
+ "network_rules_confirm_all_sites_description": "Việc này làm suy yếu bảo vệ sẵn có của mọi trang web khớp. Tiếp tục?",
+ "network_rule_template_csp": "Xóa CSP",
+ "network_rule_template_csp_desc": "Cho phép script bị CSP của trang chặn vẫn chạy được.",
+ "network_rule_template_user_agent": "Đổi User-Agent",
+ "network_rule_template_user_agent_desc": "Giả lập thiết bị hoặc trình duyệt khác.",
+ "network_rule_template_referer": "Đổi Referer",
+ "network_rule_template_referer_desc": "Sửa hoặc bỏ địa chỉ trang nguồn.",
+ "network_rule_template_response_headers": "Đổi header phản hồi",
+ "network_rule_template_response_headers_desc": "Thêm, sửa hoặc bỏ bất kỳ header phản hồi nào, không chỉ CSP.",
+ "network_rule_template_block": "Chặn yêu cầu",
+ "network_rule_template_block_desc": "Ngăn các yêu cầu khớp được gửi đi.",
+ "network_rule_template_redirect": "Chuyển hướng yêu cầu",
+ "network_rule_template_redirect_desc": "Gửi các yêu cầu khớp tới địa chỉ khác.",
+ "network_rule_template_custom": "Tùy chỉnh",
+ "network_rule_template_custom_desc": "Chỉnh trực tiếp điều kiện và hành động, gồm cả sửa header yêu cầu.",
+ "network_rules_edit_title": "Sửa quy tắc",
+ "network_rules_back_to_templates": "Quay lại mẫu",
+ "network_rules_change_template": "Đổi loại",
+ "network_rules_save": "Lưu",
+ "network_rules_edit": "Sửa",
+ "network_rules_delete": "Xóa",
+ "network_rules_confirm_delete_title": "Xóa quy tắc này?",
+ "network_rules_confirm_delete_description": "Không thể hoàn tác. Nếu chỉ tạm ngưng, hãy tắt quy tắc thay vì xóa.",
+ "network_rules_confirm_delete_action": "Xóa quy tắc",
+ "network_rules_confirm_bulk_delete_title": "Xóa {{count}} quy tắc đã chọn?",
+ "network_rules_rule_deleted": "Đã xóa quy tắc.",
+ "network_rules_field_scope": "Phạm vi áp dụng",
+ "network_rules_scope_help": "Mỗi dòng một tên miền; tự động gồm cả tên miền con.",
+ "network_rules_all_sites_warning": "Rủi ro cao; sẽ hỏi xác nhận trước khi lưu.",
+ "network_rules_field_name": "Tên quy tắc",
+ "network_rules_optional": "(tùy chọn)",
+ "network_rules_field_removed_headers": "Header phản hồi sẽ bị xóa",
+ "network_rules_csp_include_xfo": "Xóa cả X-Frame-Options",
+ "network_rules_field_user_agent": "Giá trị User-Agent",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Cách xử lý Referer",
+ "network_rules_referer_set": "Đặt thành địa chỉ cố định",
+ "network_rules_referer_remove": "Xóa Referer",
+ "network_rules_field_redirect_url": "Chuyển hướng tới",
+ "network_rules_field_action_type": "Loại hành động",
+ "network_rules_field_request_headers": "Thay đổi header yêu cầu",
+ "network_rules_field_response_headers": "Thay đổi header phản hồi",
+ "network_rules_header_name": "Tên header",
+ "network_rules_header_operation": "Thao tác",
+ "network_rules_header_value": "Giá trị",
+ "network_rules_header_op_set": "Đặt",
+ "network_rules_header_op_append": "Nối thêm",
+ "network_rules_header_op_remove": "Xóa",
+ "network_rules_add_header": "Thêm một dòng",
+ "network_rules_remove_header": "Xóa dòng này",
+ "network_rules_header_denied_note": "Không thể sửa Cookie, Authorization, Host và Origin.",
+ "network_rules_try_it": "Thử một URL",
+ "network_rules_try_match_effect": "Khớp · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "sẽ gỡ {{count}} tiêu đề phản hồi",
+ "network_rules_try_effect_modify_request_headers": "sẽ sửa {{count}} tiêu đề yêu cầu",
+ "network_rules_try_effect_modify_response_headers": "sẽ sửa {{count}} tiêu đề phản hồi",
+ "network_rules_try_effect_block": "sẽ chặn yêu cầu",
+ "network_rules_try_effect_redirect": "sẽ chuyển hướng tới {{url}}",
+ "network_rules_try_effect_allow": "sẽ cho qua yêu cầu và bỏ qua các quy tắc phía sau",
+ "network_rules_try_scope_only": "URL nằm trong phạm vi, nhưng loại tài nguyên hoặc phương thức yêu cầu đã chọn ở mục Nâng cao không bao gồm yêu cầu GET tài liệu chính này.",
+ "network_rules_try_no_match": "Không khớp với quy tắc này",
+ "network_rules_try_invalid": "Hãy nhập địa chỉ http(s) đầy đủ.",
+ "network_rules_advanced": "Nâng cao",
+ "network_rules_field_resource_types": "Loại tài nguyên",
+ "network_rules_field_request_methods": "Phương thức yêu cầu",
+ "network_rules_field_excluded_domains": "Tên miền loại trừ",
+ "network_rules_error_domain_required": "Hãy nhập ít nhất một trang web.",
+ "network_rules_error_domain_invalid": "Hãy nhập tên miền HTTP(S) hợp lệ.",
+ "network_rules_error_domain_credentials": "Hãy bỏ tên người dùng và mật khẩu khỏi URL.",
+ "network_rules_error_domain_wildcard": "Hãy nhập example.com; tên miền con được tự động bao gồm.",
+ "network_rules_error_domain_single_label": "Hãy nhập tên miền có dấu chấm, ví dụ example.com.",
+ "network_rules_error_domain_too_long": "Tên miền này quá dài.",
+ "network_rules_error_domain_count_invalid": "Mỗi quy tắc dùng từ 1 đến 100 tên miền.",
+ "network_rules_error_header_name_invalid": "Hãy nhập tên header hợp lệ.",
+ "network_rules_error_header_denied": "Cookie, Authorization, Host và Origin sẽ lộ hoặc giả mạo danh tính bên gọi nên không thể sửa.",
+ "network_rules_error_header_value_required": "Hãy nhập giá trị cho header này.",
+ "network_rules_error_redirect_url_invalid": "Hãy nhập địa chỉ đầy đủ bắt đầu bằng http(s).",
+ "network_rules_card_description": "Thay đổi cách trình duyệt xử lý các yêu cầu khớp: xóa CSP, ghi lại tiêu đề, chặn hoặc chuyển hướng.",
+ "network_rules_state_active": "Đang áp dụng",
+ "network_rules_state_paused": "Đã tạm dừng",
+ "network_rules_state_failed": "Chưa áp dụng",
+ "network_rules_summary_counts": "{{enabled}} đang bật · tổng {{total}} quy tắc",
+ "network_rules_preview_more": "còn {{count}} nữa…",
+ "network_rules_manage": "Quản lý quy tắc",
+ "network_rules_paused_title": "Tất cả quy tắc đã tạm dừng",
+ "network_rules_paused_description": "Các quy tắc được giữ lại và hoạt động trở lại ngay khi bật lại công tắc chính.",
+ "network_rules_reload_hint": "Các trang đang mở cần được tải lại.",
+ "network_rules_reload_tabs": "Tải lại thẻ",
+ "network_rules_match_description": "Nhập một URL để xem các quy tắc đang bật sẽ xử lý nó thế nào.",
+ "network_rules_match_paused": "Công tắc tổng đang tắt nên các quy tắc hiện không có hiệu lực; kết quả bên dưới mô phỏng khi bật lại.",
+ "network_rules_match_url": "URL",
+ "network_rules_match_outcome_label": "Kết quả cuối",
+ "network_rules_match_hits": "Quy tắc khớp (theo thứ tự danh sách)",
+ "network_rules_match_note": "Mô phỏng dựa trên quy tắc hiện tại; không bao gồm bản ghi khớp thực tế của trình duyệt.",
+ "network_rules_match_close": "Đóng",
+ "network_rules_outcome_none": "Không khớp",
+ "network_rules_outcome_blocked": "Bị chặn",
+ "network_rules_outcome_redirected": "Đã chuyển hướng",
+ "network_rules_outcome_allowed": "Được cho qua",
+ "network_rules_outcome_modified": "Đã sửa tiêu đề",
+ "network_rules_outcome_blocked_detail": "Yêu cầu bị chặn. Việc chặn cắt ngắn toàn bộ yêu cầu nên không quy tắc khớp nào khác chạy.",
+ "network_rules_outcome_redirected_detail": "Yêu cầu được chuyển hướng đến {{url}}.",
+ "network_rules_outcome_allowed_detail": "Yêu cầu được cho qua; các quy tắc phía dưới trong danh sách không chạy nữa.",
+ "network_rules_outcome_modified_detail": "{{count}} quy tắc ghi lại tiêu đề yêu cầu hoặc phản hồi, theo thứ tự danh sách.",
+ "network_rules_outcome_none_detail": "Không quy tắc nào đang bật khớp với yêu cầu này.",
+ "network_rules_hit_applied": "Sẽ chạy",
+ "network_rules_hit_blocked": "Yêu cầu bị chặn bởi #{{position}} nên quy tắc này không chạy",
+ "network_rules_hit_overridden": "#{{position}} đứng trước và đã quyết định kết quả nên quy tắc này không chạy",
+ "network_rules_hit_allowed": "#{{position}} đứng trước và cho yêu cầu đi qua nên quy tắc này không chạy",
+ "network_rule_resource_main_frame": "Khung chính",
+ "network_rule_resource_sub_frame": "Khung con",
+ "network_rule_resource_stylesheet": "Bảng kiểu",
+ "network_rule_resource_script": "Tập lệnh",
+ "network_rule_resource_image": "Hình ảnh",
+ "network_rule_resource_font": "Phông chữ",
+ "network_rule_resource_object": "Đối tượng",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "Báo cáo CSP",
+ "network_rule_resource_media": "Phương tiện",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "Khác"
}
diff --git a/src/locales/zh-CN/tools.json b/src/locales/zh-CN/tools.json
index 081b7c25f..50e9be64e 100644
--- a/src/locales/zh-CN/tools.json
+++ b/src/locales/zh-CN/tools.json
@@ -14,5 +14,200 @@
"local_backup": "本地备份",
"cloud_backup": "云端备份",
"auto_backup": "自动备份",
- "data_migration": "数据迁移"
+ "data_migration": "数据迁移",
+ "network_rules_title": "网络规则",
+ "network_rules_back": "返回工具",
+ "network_rules_master_switch": "总开关",
+ "network_rules_search_placeholder": "搜索规则名称或域名",
+ "network_rules_filter_action": "动作",
+ "network_rules_filter_status": "状态",
+ "network_rules_filter_all_actions": "全部动作",
+ "network_rules_filter_all_statuses": "全部状态",
+ "network_rules_status_enabled": "已启用",
+ "network_rules_status_disabled": "已停用",
+ "network_rules_test_match": "测试匹配",
+ "network_rules_new_rule": "新建规则",
+ "network_rule_action_remove_response_headers": "移除响应头",
+ "network_rule_action_modify_request_headers": "改请求头",
+ "network_rule_action_modify_response_headers": "改响应头",
+ "network_rule_action_block": "屏蔽",
+ "network_rule_action_redirect": "重定向",
+ "network_rule_action_allow": "放行",
+ "network_rules_column_name": "名称",
+ "network_rules_column_action": "动作",
+ "network_rules_column_scope": "应用范围",
+ "network_rules_column_order": "顺序",
+ "network_rules_select_all": "选择本页规则",
+ "network_rules_bulk_actions": "批量操作",
+ "network_rules_selected_count": "已选 {{count}} 条",
+ "network_rules_bulk_enable": "启用",
+ "network_rules_bulk_disable": "停用",
+ "network_rules_bulk_delete": "删除",
+ "network_rules_clear_selection": "取消选择",
+ "network_rules_select_rule": "选择 {{name}}",
+ "network_rules_drag_handle": "调整 {{name}} 的顺序",
+ "network_rules_drag_instructions": "按空格键拿起规则,用上下方向键移动位置,再按空格键放下,按 Esc 取消。",
+ "network_rules_drag_picked_up": "已拿起「{{name}}」,当前第 {{position}} 位,共 {{total}} 位。",
+ "network_rules_drag_moved_over": "「{{name}}」将放到第 {{position}} 位。",
+ "network_rules_drag_dropped": "「{{name}}」已放到第 {{position}} 位。",
+ "network_rules_drag_cancelled": "已取消拖动,「{{name}}」仍在第 {{position}} 位。",
+ "network_rules_enable_rule": "启用 {{name}}",
+ "network_rules_row_menu": "更多操作",
+ "network_rules_modified_at": "{{time}}修改",
+ "network_rules_all_websites": "所有网站",
+ "network_rules_move_top": "置顶",
+ "network_rules_move_bottom": "置底",
+ "network_rules_move_to": "移到…",
+ "network_rules_move_to_title": "移到指定位置",
+ "network_rules_move_to_label": "位置(1–{{total}})",
+ "network_rules_move_to_confirm": "移动",
+ "network_rules_cancel": "取消",
+ "network_rules_confirm": "继续",
+ "network_rules_filter_active": "筛选中 · 匹配 {{matched}} 条 / 共 {{total}} 条",
+ "network_rules_filter_drag_paused": "排序需要完整列表,已暂时停用拖拽",
+ "network_rules_clear_filters": "清除筛选",
+ "network_rules_empty_title": "还没有网络规则",
+ "network_rules_empty_description": "从一个常用场景开始,或自己新建一条",
+ "network_rules_no_results_title": "没有匹配的规则",
+ "network_rules_no_results_description": "试试其它关键词,或清除筛选条件。",
+ "network_rules_apply_error_title": "规则未能应用到浏览器",
+ "network_rules_apply_error_detail": "页面仍在使用上一版规则。",
+ "network_rules_retry": "重试",
+ "network_rules_load_error": "网络规则未能加载,请重试。",
+ "network_rules_unsupported_schema": "暂不支持此网络规则数据格式。",
+ "network_rules_footer_total": "共 {{count}} 条规则 · {{enabled}} 条启用中",
+ "network_rules_quota_total": "动态规则配额 {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "改写头 / 重定向 / 放行 {{used}}/{{limit}}",
+ "network_rules_prev_page": "上一页",
+ "network_rules_next_page": "下一页",
+ "network_rules_reorder_failed": "顺序未能保存,已恢复原顺序。",
+ "network_rules_rule_saved": "规则已生效",
+ "network_rules_rule_saved_apply_failed": "规则已保存,但浏览器规则未能更新。",
+ "network_rules_revision_conflict": "当前页面不是最新状态。请重新加载最新规则后再试。",
+ "network_rules_storage_error": "操作未能保存。",
+ "network_rules_confirm_all_sites_title": "影响所有网站?",
+ "network_rules_confirm_all_sites_description": "这会削弱所有匹配网站的内置保护。是否继续?",
+ "network_rule_template_csp": "移除 CSP",
+ "network_rule_template_csp_desc": "让被站点 CSP 拦下的脚本能正常运行",
+ "network_rule_template_user_agent": "修改 User-Agent",
+ "network_rule_template_user_agent_desc": "伪装设备或浏览器标识",
+ "network_rule_template_referer": "修改 Referer",
+ "network_rule_template_referer_desc": "改写或移除来源页地址",
+ "network_rule_template_response_headers": "修改响应头",
+ "network_rule_template_response_headers_desc": "增删改任意响应头,不限于 CSP",
+ "network_rule_template_block": "屏蔽请求",
+ "network_rule_template_block_desc": "阻止匹配到的请求发出",
+ "network_rule_template_redirect": "重定向请求",
+ "network_rule_template_redirect_desc": "把请求转到另一个地址",
+ "network_rule_template_custom": "自定义",
+ "network_rule_template_custom_desc": "直接编辑条件与动作,含请求头改写等未列出的场景",
+ "network_rules_edit_title": "编辑规则",
+ "network_rules_back_to_templates": "返回模板",
+ "network_rules_change_template": "更换类型",
+ "network_rules_save": "保存",
+ "network_rules_edit": "编辑",
+ "network_rules_delete": "删除",
+ "network_rules_confirm_delete_title": "删除这条规则?",
+ "network_rules_confirm_delete_description": "删除后无法恢复。如果只是暂时不用,可以改为停用。",
+ "network_rules_confirm_delete_action": "删除规则",
+ "network_rules_confirm_bulk_delete_title": "删除选中的 {{count}} 条规则?",
+ "network_rules_rule_deleted": "规则已删除。",
+ "network_rules_field_scope": "应用范围",
+ "network_rules_scope_help": "一行一个域名,自动包含子域名。",
+ "network_rules_all_sites_warning": "风险较高,保存前会再次确认",
+ "network_rules_field_name": "规则名称",
+ "network_rules_optional": "(可选)",
+ "network_rules_field_removed_headers": "将移除的响应头",
+ "network_rules_csp_include_xfo": "同时移除 X-Frame-Options",
+ "network_rules_field_user_agent": "User-Agent 值",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer 处理",
+ "network_rules_referer_set": "设为指定地址",
+ "network_rules_referer_remove": "移除 Referer",
+ "network_rules_field_redirect_url": "重定向到",
+ "network_rules_field_action_type": "动作类型",
+ "network_rules_field_request_headers": "请求头改写",
+ "network_rules_field_response_headers": "响应头改写",
+ "network_rules_header_name": "头名称",
+ "network_rules_header_operation": "操作",
+ "network_rules_header_value": "值",
+ "network_rules_header_op_set": "设置",
+ "network_rules_header_op_append": "追加",
+ "network_rules_header_op_remove": "移除",
+ "network_rules_add_header": "添加一行",
+ "network_rules_remove_header": "删除这一行",
+ "network_rules_header_denied_note": "Cookie、Authorization、Host、Origin 不允许改写。",
+ "network_rules_try_it": "试一试",
+ "network_rules_try_match_effect": "匹配 · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "将移除 {{count}} 个响应头",
+ "network_rules_try_effect_modify_request_headers": "将改写 {{count}} 个请求头",
+ "network_rules_try_effect_modify_response_headers": "将改写 {{count}} 个响应头",
+ "network_rules_try_effect_block": "将屏蔽该请求",
+ "network_rules_try_effect_redirect": "将重定向到 {{url}}",
+ "network_rules_try_effect_allow": "将放行该请求,并跳过其后的规则",
+ "network_rules_try_scope_only": "网址在范围内,但「高级选项」里限定的资源类型或请求方法不覆盖这次测试的主文档 GET 请求。",
+ "network_rules_try_no_match": "不匹配",
+ "network_rules_try_invalid": "请输入完整的 http(s) 地址。",
+ "network_rules_advanced": "高级选项",
+ "network_rules_field_resource_types": "资源类型",
+ "network_rules_field_request_methods": "请求方法",
+ "network_rules_field_excluded_domains": "排除域名",
+ "network_rules_error_domain_required": "至少输入一个网站。",
+ "network_rules_error_domain_invalid": "请输入有效的 HTTP(S) 网站域名。",
+ "network_rules_error_domain_credentials": "请从网址中移除用户名和密码。",
+ "network_rules_error_domain_wildcard": "请输入 example.com;系统会自动包含子域名。",
+ "network_rules_error_domain_single_label": "请输入包含点号的域名,例如 example.com。",
+ "network_rules_error_domain_too_long": "此域名过长。",
+ "network_rules_error_domain_count_invalid": "每条规则请输入 1 至 100 个域名。",
+ "network_rules_error_header_name_invalid": "请输入合法的请求头名称。",
+ "network_rules_error_header_denied": "Cookie、Authorization、Host、Origin 会暴露或伪造调用者身份,不允许改写。",
+ "network_rules_error_header_value_required": "请填写这个请求头的值。",
+ "network_rules_error_redirect_url_invalid": "请输入 http(s) 开头的完整地址。",
+ "network_rules_card_description": "修改浏览器对匹配请求的处理:移除 CSP、改写请求头、屏蔽或重定向",
+ "network_rules_state_active": "已生效",
+ "network_rules_state_paused": "已暂停",
+ "network_rules_state_failed": "未生效",
+ "network_rules_summary_counts": "{{enabled}} 条启用中 · 共 {{total}} 条规则",
+ "network_rules_preview_more": "另有 {{count}} 条…",
+ "network_rules_manage": "管理规则",
+ "network_rules_paused_title": "所有规则已暂停",
+ "network_rules_paused_description": "规则保留,重新打开总开关后立即恢复",
+ "network_rules_reload_hint": "已打开的页面需要刷新才会应用",
+ "network_rules_reload_tabs": "刷新标签页",
+ "network_rules_match_description": "输入一个网址,看看当前启用的规则会怎么处理它",
+ "network_rules_match_paused": "总开关已关闭,这些规则当前不生效;以下是重新打开后的模拟结果。",
+ "network_rules_match_url": "网址",
+ "network_rules_match_outcome_label": "最终结果",
+ "network_rules_match_hits": "命中的规则(按列表顺序)",
+ "network_rules_match_note": "基于当前规则的模拟结果,不包含浏览器的实际命中记录",
+ "network_rules_match_close": "关闭",
+ "network_rules_outcome_none": "无命中",
+ "network_rules_outcome_blocked": "屏蔽",
+ "network_rules_outcome_redirected": "重定向",
+ "network_rules_outcome_allowed": "放行",
+ "network_rules_outcome_modified": "改写头",
+ "network_rules_outcome_blocked_detail": "请求会被屏蔽。屏蔽会短路整个请求,其余命中规则都不会执行。",
+ "network_rules_outcome_redirected_detail": "请求会被重定向到 {{url}}。",
+ "network_rules_outcome_allowed_detail": "请求会被放行,顺序更靠后的规则不再执行。",
+ "network_rules_outcome_modified_detail": "共 {{count}} 条规则会按列表顺序依次改写请求头或响应头。",
+ "network_rules_outcome_none_detail": "当前启用的规则都不匹配这个请求。",
+ "network_rules_hit_applied": "会执行",
+ "network_rules_hit_blocked": "请求已被 #{{position}} 屏蔽,不会执行",
+ "network_rules_hit_overridden": "顺序更靠前的 #{{position}} 已决定结果,不会执行",
+ "network_rules_hit_allowed": "顺序更靠前的 #{{position}} 已放行请求,不会执行",
+ "network_rule_resource_main_frame": "主框架",
+ "network_rule_resource_sub_frame": "子框架",
+ "network_rule_resource_stylesheet": "样式表",
+ "network_rule_resource_script": "脚本",
+ "network_rule_resource_image": "图片",
+ "network_rule_resource_font": "字体",
+ "network_rule_resource_object": "对象",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP 报告",
+ "network_rule_resource_media": "媒体",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "其他"
}
diff --git a/src/locales/zh-TW/tools.json b/src/locales/zh-TW/tools.json
index 8d473c46c..cb9e930e1 100644
--- a/src/locales/zh-TW/tools.json
+++ b/src/locales/zh-TW/tools.json
@@ -14,5 +14,200 @@
"local_backup": "本機備份",
"cloud_backup": "雲端備份",
"auto_backup": "自動備份",
- "data_migration": "資料遷移"
+ "data_migration": "資料遷移",
+ "network_rules_title": "網路規則",
+ "network_rules_back": "返回工具",
+ "network_rules_master_switch": "總開關",
+ "network_rules_search_placeholder": "搜尋規則名稱或網域",
+ "network_rules_filter_action": "動作",
+ "network_rules_filter_status": "狀態",
+ "network_rules_filter_all_actions": "全部動作",
+ "network_rules_filter_all_statuses": "全部狀態",
+ "network_rules_status_enabled": "已啟用",
+ "network_rules_status_disabled": "已停用",
+ "network_rules_test_match": "測試比對",
+ "network_rules_new_rule": "新增規則",
+ "network_rule_action_remove_response_headers": "移除回應標頭",
+ "network_rule_action_modify_request_headers": "改請求標頭",
+ "network_rule_action_modify_response_headers": "改回應標頭",
+ "network_rule_action_block": "封鎖",
+ "network_rule_action_redirect": "重新導向",
+ "network_rule_action_allow": "放行",
+ "network_rules_column_name": "名稱",
+ "network_rules_column_action": "動作",
+ "network_rules_column_scope": "套用範圍",
+ "network_rules_column_order": "順序",
+ "network_rules_select_all": "選取本頁規則",
+ "network_rules_bulk_actions": "批次操作",
+ "network_rules_selected_count": "已選 {{count}} 條",
+ "network_rules_bulk_enable": "啟用",
+ "network_rules_bulk_disable": "停用",
+ "network_rules_bulk_delete": "刪除",
+ "network_rules_clear_selection": "取消選取",
+ "network_rules_select_rule": "選取 {{name}}",
+ "network_rules_drag_handle": "調整 {{name}} 的順序",
+ "network_rules_drag_instructions": "按空白鍵拿起規則,用上下方向鍵移動位置,再按空白鍵放下,按 Esc 取消。",
+ "network_rules_drag_picked_up": "已拿起「{{name}}」,目前第 {{position}} 位,共 {{total}} 位。",
+ "network_rules_drag_moved_over": "「{{name}}」將放到第 {{position}} 位。",
+ "network_rules_drag_dropped": "「{{name}}」已放到第 {{position}} 位。",
+ "network_rules_drag_cancelled": "已取消拖曳,「{{name}}」仍在第 {{position}} 位。",
+ "network_rules_enable_rule": "啟用 {{name}}",
+ "network_rules_row_menu": "更多操作",
+ "network_rules_modified_at": "{{time}}修改",
+ "network_rules_all_websites": "所有網站",
+ "network_rules_move_top": "移到最上",
+ "network_rules_move_bottom": "移到最下",
+ "network_rules_move_to": "移到…",
+ "network_rules_move_to_title": "移到指定位置",
+ "network_rules_move_to_label": "位置(1–{{total}})",
+ "network_rules_move_to_confirm": "移動",
+ "network_rules_cancel": "取消",
+ "network_rules_confirm": "繼續",
+ "network_rules_filter_active": "篩選中 · 符合 {{matched}} 條 / 共 {{total}} 條",
+ "network_rules_filter_drag_paused": "排序需要完整清單,已暫時停用拖曳",
+ "network_rules_clear_filters": "清除篩選",
+ "network_rules_empty_title": "還沒有網路規則",
+ "network_rules_empty_description": "從一個常用情境開始,或自己新增一條",
+ "network_rules_no_results_title": "沒有符合的規則",
+ "network_rules_no_results_description": "試試其他關鍵字,或清除篩選條件。",
+ "network_rules_apply_error_title": "規則未能套用到瀏覽器",
+ "network_rules_apply_error_detail": "頁面仍在使用上一版規則。",
+ "network_rules_retry": "重試",
+ "network_rules_load_error": "網路規則未能載入,請重試。",
+ "network_rules_unsupported_schema": "暫不支援此網路規則資料格式。",
+ "network_rules_footer_total": "共 {{count}} 條規則 · {{enabled}} 條啟用中",
+ "network_rules_quota_total": "動態規則配額 {{used}}/{{limit}}",
+ "network_rules_quota_unsafe": "改寫標頭 / 重新導向 / 放行 {{used}}/{{limit}}",
+ "network_rules_prev_page": "上一頁",
+ "network_rules_next_page": "下一頁",
+ "network_rules_reorder_failed": "順序未能儲存,已還原原順序。",
+ "network_rules_rule_saved": "規則已生效",
+ "network_rules_rule_saved_apply_failed": "規則已儲存,但瀏覽器規則未能更新。",
+ "network_rules_revision_conflict": "目前頁面不是最新狀態。請重新載入最新規則後再試。",
+ "network_rules_storage_error": "操作未能儲存。",
+ "network_rules_confirm_all_sites_title": "影響所有網站?",
+ "network_rules_confirm_all_sites_description": "這會削弱所有符合網站的內建保護。是否繼續?",
+ "network_rule_template_csp": "移除 CSP",
+ "network_rule_template_csp_desc": "讓被網站 CSP 擋下的腳本能正常執行",
+ "network_rule_template_user_agent": "修改 User-Agent",
+ "network_rule_template_user_agent_desc": "偽裝裝置或瀏覽器識別",
+ "network_rule_template_referer": "修改 Referer",
+ "network_rule_template_referer_desc": "改寫或移除來源頁位址",
+ "network_rule_template_response_headers": "修改回應標頭",
+ "network_rule_template_response_headers_desc": "新增、修改或移除任意回應標頭,不限於 CSP",
+ "network_rule_template_block": "封鎖請求",
+ "network_rule_template_block_desc": "阻止符合條件的請求送出",
+ "network_rule_template_redirect": "重新導向請求",
+ "network_rule_template_redirect_desc": "把請求轉到另一個位址",
+ "network_rule_template_custom": "自訂",
+ "network_rule_template_custom_desc": "直接編輯條件與動作,包含請求標頭改寫等未列出的情境",
+ "network_rules_edit_title": "編輯規則",
+ "network_rules_back_to_templates": "返回範本",
+ "network_rules_change_template": "更換類型",
+ "network_rules_save": "儲存",
+ "network_rules_edit": "編輯",
+ "network_rules_delete": "刪除",
+ "network_rules_confirm_delete_title": "刪除這條規則?",
+ "network_rules_confirm_delete_description": "刪除後無法復原。如果只是暫時不用,可以改為停用。",
+ "network_rules_confirm_delete_action": "刪除規則",
+ "network_rules_confirm_bulk_delete_title": "刪除選取的 {{count}} 條規則?",
+ "network_rules_rule_deleted": "規則已刪除。",
+ "network_rules_field_scope": "套用範圍",
+ "network_rules_scope_help": "一行一個網域,自動包含子網域。",
+ "network_rules_all_sites_warning": "風險較高,儲存前會再次確認",
+ "network_rules_field_name": "規則名稱",
+ "network_rules_optional": "(選填)",
+ "network_rules_field_removed_headers": "將移除的回應標頭",
+ "network_rules_csp_include_xfo": "同時移除 X-Frame-Options",
+ "network_rules_field_user_agent": "User-Agent 值",
+ "network_rules_ua_preset_iphone": "iPhone · Safari",
+ "network_rules_ua_preset_android": "Android · Chrome",
+ "network_rules_ua_preset_windows": "Windows · Chrome",
+ "network_rules_field_referer": "Referer 處理",
+ "network_rules_referer_set": "設為指定位址",
+ "network_rules_referer_remove": "移除 Referer",
+ "network_rules_field_redirect_url": "重新導向至",
+ "network_rules_field_action_type": "動作類型",
+ "network_rules_field_request_headers": "請求標頭改寫",
+ "network_rules_field_response_headers": "回應標頭改寫",
+ "network_rules_header_name": "標頭名稱",
+ "network_rules_header_operation": "操作",
+ "network_rules_header_value": "值",
+ "network_rules_header_op_set": "設定",
+ "network_rules_header_op_append": "附加",
+ "network_rules_header_op_remove": "移除",
+ "network_rules_add_header": "新增一列",
+ "network_rules_remove_header": "刪除這一列",
+ "network_rules_header_denied_note": "Cookie、Authorization、Host、Origin 不允許改寫。",
+ "network_rules_try_it": "試一試",
+ "network_rules_try_match_effect": "符合 · {{effect}}",
+ "network_rules_try_effect_remove_response_headers": "將移除 {{count}} 個回應標頭",
+ "network_rules_try_effect_modify_request_headers": "將改寫 {{count}} 個請求標頭",
+ "network_rules_try_effect_modify_response_headers": "將改寫 {{count}} 個回應標頭",
+ "network_rules_try_effect_block": "將封鎖該請求",
+ "network_rules_try_effect_redirect": "將重新導向至 {{url}}",
+ "network_rules_try_effect_allow": "將放行該請求,並略過其後的規則",
+ "network_rules_try_scope_only": "網址在範圍內,但「進階選項」裡限定的資源類型或請求方法未涵蓋這次測試的主文件 GET 請求。",
+ "network_rules_try_no_match": "不符合",
+ "network_rules_try_invalid": "請輸入完整的 http(s) 位址。",
+ "network_rules_advanced": "進階選項",
+ "network_rules_field_resource_types": "資源類型",
+ "network_rules_field_request_methods": "請求方法",
+ "network_rules_field_excluded_domains": "排除網域",
+ "network_rules_error_domain_required": "至少輸入一個網站。",
+ "network_rules_error_domain_invalid": "請輸入有效的 HTTP(S) 網站網域。",
+ "network_rules_error_domain_credentials": "請從網址中移除使用者名稱和密碼。",
+ "network_rules_error_domain_wildcard": "請輸入 example.com;系統會自動包含子網域。",
+ "network_rules_error_domain_single_label": "請輸入包含點號的網域,例如 example.com。",
+ "network_rules_error_domain_too_long": "此網域過長。",
+ "network_rules_error_domain_count_invalid": "每條規則請輸入 1 至 100 個網域。",
+ "network_rules_error_header_name_invalid": "請輸入合法的標頭名稱。",
+ "network_rules_error_header_denied": "Cookie、Authorization、Host、Origin 會暴露或偽造呼叫者身分,不允許改寫。",
+ "network_rules_error_header_value_required": "請填寫這個標頭的值。",
+ "network_rules_error_redirect_url_invalid": "請輸入 http(s) 開頭的完整位址。",
+ "network_rules_card_description": "修改瀏覽器對符合請求的處理:移除 CSP、改寫請求標頭、封鎖或重新導向",
+ "network_rules_state_active": "已生效",
+ "network_rules_state_paused": "已暫停",
+ "network_rules_state_failed": "未生效",
+ "network_rules_summary_counts": "{{enabled}} 條啟用中 · 共 {{total}} 條規則",
+ "network_rules_preview_more": "另有 {{count}} 條…",
+ "network_rules_manage": "管理規則",
+ "network_rules_paused_title": "所有規則已暫停",
+ "network_rules_paused_description": "規則保留,重新開啟總開關後立即恢復",
+ "network_rules_reload_hint": "已開啟的頁面需要重新整理才會套用",
+ "network_rules_reload_tabs": "重新整理分頁",
+ "network_rules_match_description": "輸入一個網址,看看目前啟用的規則會怎麼處理它",
+ "network_rules_match_paused": "總開關已關閉,這些規則目前不生效;以下是重新開啟後的模擬結果。",
+ "network_rules_match_url": "網址",
+ "network_rules_match_outcome_label": "最終結果",
+ "network_rules_match_hits": "命中的規則(按列表順序)",
+ "network_rules_match_note": "根據目前規則的模擬結果,不包含瀏覽器的實際命中記錄",
+ "network_rules_match_close": "關閉",
+ "network_rules_outcome_none": "無命中",
+ "network_rules_outcome_blocked": "封鎖",
+ "network_rules_outcome_redirected": "重新導向",
+ "network_rules_outcome_allowed": "放行",
+ "network_rules_outcome_modified": "改寫標頭",
+ "network_rules_outcome_blocked_detail": "請求會被封鎖。封鎖會短路整個請求,其餘命中規則都不會執行。",
+ "network_rules_outcome_redirected_detail": "請求會被重新導向到 {{url}}。",
+ "network_rules_outcome_allowed_detail": "請求會被放行,順序更靠後的規則不再執行。",
+ "network_rules_outcome_modified_detail": "共 {{count}} 條規則會按列表順序依次改寫請求標頭或回應標頭。",
+ "network_rules_outcome_none_detail": "目前啟用的規則都不符合這個請求。",
+ "network_rules_hit_applied": "會執行",
+ "network_rules_hit_blocked": "請求已被 #{{position}} 封鎖,不會執行",
+ "network_rules_hit_overridden": "順序更靠前的 #{{position}} 已決定結果,不會執行",
+ "network_rules_hit_allowed": "順序更靠前的 #{{position}} 已放行請求,不會執行",
+ "network_rule_resource_main_frame": "主框架",
+ "network_rule_resource_sub_frame": "子框架",
+ "network_rule_resource_stylesheet": "樣式表",
+ "network_rule_resource_script": "腳本",
+ "network_rule_resource_image": "圖片",
+ "network_rule_resource_font": "字型",
+ "network_rule_resource_object": "物件",
+ "network_rule_resource_xmlhttprequest": "XHR",
+ "network_rule_resource_ping": "Ping",
+ "network_rule_resource_csp_report": "CSP 報告",
+ "network_rule_resource_media": "媒體",
+ "network_rule_resource_websocket": "WebSocket",
+ "network_rule_resource_other": "其他"
}
diff --git a/src/manifest.test.ts b/src/manifest.test.ts
new file mode 100644
index 000000000..ec2998cfe
--- /dev/null
+++ b/src/manifest.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import manifest from "./manifest.json";
+
+describe("扩展隐私上下文配置", () => {
+ it("保留 split 以支持现有隐私窗口流程", () => {
+ expect(manifest.incognito).toBe("split");
+ });
+});
+
+describe("网络规则所需权限", () => {
+ it("匹配测试是纯前端模拟,不申请 declarativeNetRequestFeedback", () => {
+ const permissions: string[] = [...manifest.permissions, ...manifest.optional_permissions];
+ expect(permissions).toContain("declarativeNetRequest");
+ expect(permissions).not.toContain("declarativeNetRequestFeedback");
+ });
+});
diff --git a/src/pages/components/ui/pagination.tsx b/src/pages/components/ui/pagination.tsx
new file mode 100644
index 000000000..4ad6a74a8
--- /dev/null
+++ b/src/pages/components/ui/pagination.tsx
@@ -0,0 +1,66 @@
+import type React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { cn } from "@App/pkg/utils/cn";
+import { Button } from "./button";
+
+type PaginationProps = React.ComponentProps<"nav"> & {
+ page: number;
+ pageCount: number;
+ onPageChange: (page: number) => void;
+ previousLabel: string;
+ nextLabel: string;
+};
+
+/** 当前页两侧各保留一个页码,首尾页恒常可见,其余折叠为省略号。 */
+function pageItems(page: number, pageCount: number): (number | "gap")[] {
+ const pages = new Set([1, pageCount, page - 1, page, page + 1]);
+ const visible = [...pages].filter((value) => value >= 1 && value <= pageCount).sort((a, b) => a - b);
+ return visible.flatMap((value, index) =>
+ index > 0 && value - visible[index - 1] > 1 ? ["gap" as const, value] : [value]
+ );
+}
+
+function Pagination({ page, pageCount, onPageChange, previousLabel, nextLabel, className, ...props }: PaginationProps) {
+ return (
+
+ );
+}
+
+export { Pagination };
diff --git a/src/pages/components/ui/table.tsx b/src/pages/components/ui/table.tsx
new file mode 100644
index 000000000..f03687fbc
--- /dev/null
+++ b/src/pages/components/ui/table.tsx
@@ -0,0 +1,52 @@
+import type React from "react";
+import { cn } from "@App/pkg/utils/cn";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return ;
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return | ;
+}
+
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/src/pages/options/App.tsx b/src/pages/options/App.tsx
index e9c0a56ec..e98d84fd2 100644
--- a/src/pages/options/App.tsx
+++ b/src/pages/options/App.tsx
@@ -6,6 +6,7 @@ import ScriptEditor from "./routes/ScriptEditor";
import Logger from "./routes/Logger";
import Setting from "./routes/Setting";
import Tools from "./routes/Tools";
+import NetworkRules from "./routes/Tools/NetworkRules";
import AgentChat from "./routes/Agent/Chat";
import AgentSkills from "./routes/Agent/Skills";
import AgentProvider from "./routes/Agent/Provider";
@@ -96,6 +97,7 @@ const router = createHashRouter([
{ path: "logs", element: },
{ path: "logger", element: },
{ path: "tools", element: },
+ { path: "tools/network-rules", element: },
{ path: "settings", element: },
{ path: "setting", element: },
{ path: "script/editor/:uuid?", element: },
diff --git a/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx b/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx
new file mode 100644
index 000000000..4919e4293
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/ActionColors.test.tsx
@@ -0,0 +1,145 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+import { parseRuleDomains } from "@App/pkg/utils/network_rule_condition";
+
+import NetworkRules from ".";
+import RuleForm, { type RuleFormState } from "./RuleForm";
+import RuleSheet from "./RuleSheet";
+import { ACTION_TONES } from "./RuleParts";
+import { emptyActionDraft } from "./templates";
+import { stubNotify } from "./test-helpers";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(id: string, name: string, action: NetworkRule["action"]): NetworkRule {
+ return {
+ id,
+ name,
+ enabled: true,
+ condition: { requestDomains: ["example.com"] },
+ action,
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+function renderPage(rules: NetworkRule[]) {
+ const snapshot: NetworkRuleSnapshot = {
+ state: { schemaVersion: 1, revision: 3, masterEnabled: true, rules, order: rules.map((r) => r.id) },
+ apply: { state: "applied", revision: 3, appliedAt: 1 },
+ };
+ renderWithThemeRouter(
+
+
+ }
+ />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+}
+
+/** 模板卡的图标片是按钮的第一个子元素,颜色只落在它身上,标题与描述用的是通用文字色。 */
+function templateIcon(title: string): HTMLElement {
+ return screen.getByRole("button", { name: new RegExp(title) }).firstElementChild as HTMLElement;
+}
+
+describe("网络规则的动作配色", () => {
+ it("六种动作各有一种颜色,且都取自 label-* 令牌族的同一色相", () => {
+ const tones = Object.values(ACTION_TONES);
+
+ expect(tones).toHaveLength(6);
+ expect(new Set(tones).size).toBe(6);
+ for (const tone of tones) {
+ const hues = /^bg-label-([a-z]+)-bg text-label-([a-z]+)-fg$/.exec(tone);
+ expect(hues, tone).not.toBeNull();
+ expect(hues![1]).toBe(hues![2]);
+ }
+ });
+
+ it("列表页的动作徽标按动作类型上色,而不是一律中性", async () => {
+ renderPage([
+ rule("r1", "移除 CSP", cspRemovalAction()),
+ rule("r2", "屏蔽上报", { type: "block" }),
+ rule("r3", "改 UA", {
+ type: "modifyRequestHeaders",
+ headers: [{ header: "user-agent", operation: "set", value: "x" }],
+ }),
+ ]);
+
+ expect((await screen.findByText("移除响应头")).className).toContain(ACTION_TONES.removeResponseHeaders);
+ expect(screen.getByText("屏蔽").className).toContain(ACTION_TONES.block);
+ expect(screen.getByText("改请求头").className).toContain(ACTION_TONES.modifyRequestHeaders);
+ expect(screen.getByText("屏蔽").className).not.toContain("bg-secondary");
+ });
+
+ it("选模板卡片的图标片取该模板动作的颜色,「自定义」没有固定动作因而保持中性", () => {
+ render( {}} onSave={async () => true} />);
+
+ expect(templateIcon("移除 CSP").className).toContain(ACTION_TONES.removeResponseHeaders);
+ expect(templateIcon("修改 User-Agent").className).toContain(ACTION_TONES.modifyRequestHeaders);
+ expect(templateIcon("修改 Referer").className).toContain(ACTION_TONES.modifyRequestHeaders);
+ expect(templateIcon("修改响应头").className).toContain(ACTION_TONES.modifyResponseHeaders);
+ expect(templateIcon("屏蔽请求").className).toContain(ACTION_TONES.block);
+ expect(templateIcon("重定向请求").className).toContain(ACTION_TONES.redirect);
+ expect(templateIcon("自定义").className).toContain("bg-muted");
+ });
+
+ it("表单里的动作徽标与被移除的响应头名用同一个动作色", () => {
+ const state: RuleFormState = {
+ websites: "example.com",
+ allSites: false,
+ name: "",
+ draft: emptyActionDraft("csp"),
+ resourceTypes: [],
+ requestMethods: [],
+ excludedWebsites: "",
+ tryUrl: "",
+ };
+ render(
+ {}}
+ onBlurScope={() => {}}
+ onChangeTemplate={() => {}}
+ onCancel={() => {}}
+ onSubmit={() => {}}
+ />
+ );
+
+ const tone: string = ACTION_TONES.removeResponseHeaders;
+ expect(screen.getByText("移除 CSP").className).toContain(tone);
+ expect(screen.getByText("content-security-policy").className).toContain(tone);
+ // 应用范围的域名不是动作的一部分,仍是中性 chip。同名文本还出现在范围输入框的 placeholder 上。
+ const domainChip = screen.getAllByText("example.com").find((el) => el.dataset.slot === "badge");
+ expect(domainChip!.className).not.toContain("label-");
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx
new file mode 100644
index 000000000..aae70394e
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/BulkActions.test.tsx
@@ -0,0 +1,227 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { notify } from "@App/pages/components/ui/toast";
+import { cspRemovalAction, type NetworkRule, type NetworkRuleState } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleMutationResult } from "@App/app/service/service_worker/network_rule";
+
+import NetworkRules from ".";
+import { NETWORK_RULES_PAGE_SIZE } from "./rules";
+import { stubNotify } from "./test-helpers";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(index: number, over: Partial = {}): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ ...over,
+ };
+}
+
+/** mock 真实地推进 revision 与规则集,批量操作才会暴露「用陈旧 revision」这类错误。 */
+function clientFor(rules: NetworkRule[], overrides: Partial = {}): NetworkRuleClient {
+ let state: NetworkRuleState = {
+ schemaVersion: 1,
+ revision: 3,
+ masterEnabled: true,
+ rules,
+ order: rules.map((r) => r.id),
+ };
+ const snapshot = () => ({ state, apply: { state: "applied" as const, revision: state.revision, appliedAt: 1 } });
+ const commit = (next: Partial): NetworkRuleMutationResult => {
+ state = { ...state, ...next, revision: state.revision + 1 };
+ return { ...snapshot(), outcome: "applied" as const };
+ };
+ return {
+ getState: vi.fn(async () => snapshot()),
+ createRule: vi.fn(),
+ updateRule: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ reorderRules: vi.fn(),
+ retryApply: vi.fn(),
+ setRulesEnabled: vi.fn(async ({ ids, enabled }: { ids: string[]; enabled: boolean }) =>
+ commit({ rules: state.rules.map((r) => (ids.includes(r.id) ? { ...r, enabled } : r)) })
+ ),
+ deleteRules: vi.fn(async ({ ids }: { ids: string[] }) =>
+ commit({
+ rules: state.rules.filter((r) => !ids.includes(r.id)),
+ order: state.order.filter((o) => !ids.includes(o)),
+ })
+ ),
+ ...overrides,
+ } as unknown as NetworkRuleClient;
+}
+
+function renderPage(client: NetworkRuleClient) {
+ return renderWithThemeRouter(
+
+ } />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+}
+
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+// 行勾选框与翻页按钮都按 aria-label 取:整页 role 扫描要给每个同角色元素算一遍可访问名,
+// 20 行的表上单次 *ByRole 就要 20~70ms,够把这一文件顶出 ui 项目 850ms 的预算。
+function selectRow(name: string) {
+ fireEvent.click(screen.getByLabelText(`选择 ${name}`));
+}
+
+function bulkBar() {
+ return screen.getByRole("toolbar", { name: "批量操作" });
+}
+
+function clickBulk(label: string) {
+ fireEvent.click(within(bulkBar()).getByRole("button", { name: label }));
+}
+
+function argsOf(mock: unknown) {
+ return vi
+ .mocked(mock as (input: { baseRevision: number; ids: string[]; enabled?: boolean }) => unknown)
+ .mock.calls.map((call) => call[0]);
+}
+
+describe("网络规则批量操作", () => {
+ // 整页挂载 + 两次勾选 + 一次批量往返:本地 solo 覆盖率下 320ms,而 GitHub runner 约慢 2.5 倍,
+ // 本文件首例还要多付一次冷启动,850ms 的 ui 预算在 CI 上连挂两轮。给这一条单独放宽,
+ // 预算对其余用例保持不变。
+ it("未选中时没有操作栏,选中两行后批量停用只对这两条发出请求", { timeout: 1500 }, async () => {
+ const client = clientFor([rule(1), rule(2), rule(3)]);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
+
+ selectRow("规则 1");
+ selectRow("规则 3");
+ expect(within(bulkBar()).getByText("已选 2 条")).toBeInTheDocument();
+
+ clickBulk("停用");
+ await flush();
+
+ // 一次用户操作只发一次请求:服务端在同一次写入里改完这两条。
+ expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"], enabled: false }]);
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
+ });
+
+ it("批量删除在确认前不动手,确认框提示可以改用停用", async () => {
+ const client = clientFor([rule(1), rule(2), rule(3)]);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ selectRow("规则 1");
+ selectRow("规则 3");
+ clickBulk("删除");
+ await flush();
+
+ expect(client.deleteRules).not.toHaveBeenCalled();
+ const dialog = screen.getByRole("alertdialog");
+ expect(within(dialog).getByText("删除选中的 2 条规则?")).toBeInTheDocument();
+ expect(within(dialog).getByText(/停用/)).toBeInTheDocument();
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "删除规则" }));
+ await flush();
+
+ expect(argsOf(client.deleteRules)).toEqual([{ baseRevision: 3, ids: ["r1", "r3"] }]);
+ expect(screen.queryByText("规则 1")).not.toBeInTheDocument();
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
+ });
+
+ it("批量启用只处理当前停用的规则,含「所有网站」时仍需二次确认", async () => {
+ const client = clientFor([
+ rule(1),
+ rule(2, { enabled: false, condition: { urlFilter: "*" } }),
+ rule(3, { enabled: false }),
+ ]);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ selectRow("规则 1");
+ selectRow("规则 2");
+ selectRow("规则 3");
+ clickBulk("启用");
+ await flush();
+
+ expect(client.setRulesEnabled).not.toHaveBeenCalled();
+ fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "继续" }));
+ await flush();
+
+ expect(argsOf(client.setRulesEnabled)).toEqual([{ baseRevision: 3, ids: ["r2", "r3"], enabled: true }]);
+ });
+
+ it("批量删除被拒绝时一条都没删,列表与选中项原样保留", async () => {
+ const client = clientFor([rule(1), rule(2), rule(3)], {
+ deleteRules: vi.fn().mockRejectedValue({ code: "revision_conflict" }),
+ } as unknown as Partial);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ selectRow("规则 1");
+ selectRow("规则 2");
+ selectRow("规则 3");
+ clickBulk("删除");
+ await flush();
+ fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除规则" }));
+ await flush();
+
+ // 全体或全不:整批被拒绝时不能有任何一条已经消失。
+ expect(client.deleteRules).toHaveBeenCalledTimes(1);
+ for (const name of ["规则 1", "规则 2", "规则 3"]) {
+ expect(screen.getByText(name)).toBeInTheDocument();
+ }
+ expect(within(bulkBar()).getByText("已选 3 条")).toBeInTheDocument();
+ expect(notify.error).toHaveBeenCalledTimes(1);
+ expect(notify.success).not.toHaveBeenCalled();
+ });
+
+ it("翻页会清空选择,操作栏随之消失", async () => {
+ // 刚好多出一条即可翻到第二页,多余的行只会让整表重渲染更贵。
+ const client = clientFor(Array.from({ length: NETWORK_RULES_PAGE_SIZE + 1 }, (_, index) => rule(index)));
+ renderPage(client);
+ expect(await screen.findByText("规则 0")).toBeInTheDocument();
+
+ selectRow("规则 0");
+ expect(bulkBar()).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText("下一页"));
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
+ });
+
+ it("改筛选会清空选择,操作栏随之消失", async () => {
+ const client = clientFor([rule(1), rule(2), rule(3)]);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ selectRow("规则 1");
+ expect(bulkBar()).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 1" } });
+ // 选中的那行仍在筛选结果里,操作栏照样消失,说明清空来自筛选变化而不是该行被过滤掉。
+ expect(screen.getByText("规则 1")).toBeInTheDocument();
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx b/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx
new file mode 100644
index 000000000..2e2653149
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/DragAccessibility.test.tsx
@@ -0,0 +1,159 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import RuleCards from "./RuleCards";
+import RuleTable from "./RuleTable";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+});
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(index: number): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+const RULES = [rule(1), rule(2), rule(3)];
+const onToggleEnabled = vi.fn();
+
+const listProps = {
+ rules: RULES,
+ positionOf: (target: NetworkRule) => RULES.findIndex((r) => r.id === target.id) + 1,
+ total: RULES.length,
+ dragDisabled: false,
+ busy: false,
+ onToggleEnabled,
+ onDragEnd: vi.fn(),
+ onEdit: vi.fn(),
+ onDelete: vi.fn(),
+ onMoveTop: vi.fn(),
+ onMoveBottom: vi.fn(),
+ onMoveTo: vi.fn(),
+};
+
+function renderTable() {
+ render(()} onSelect={vi.fn()} onSelectPage={vi.fn()} />);
+}
+
+function handleOf(container: HTMLElement, name: string) {
+ return within(container).getByRole("button", { name: `调整 ${name} 的顺序` });
+}
+
+function announcement(): string {
+ return screen.getByRole("status").textContent ?? "";
+}
+
+/** dnd-kit 依赖真实排版测量,happy-dom 无布局,用行序号伪造矩形驱动传感器。 */
+function stubRowRects() {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
+ const row = this.closest("[data-testid='network-rule-row']") as HTMLElement | null;
+ const index = row ? screen.getAllByTestId("network-rule-row").indexOf(row) : -1;
+ const top = index < 0 ? 0 : index * 60;
+ return {
+ x: 0,
+ y: top,
+ top,
+ left: 0,
+ right: 900,
+ bottom: top + 60,
+ width: 900,
+ height: 60,
+ toJSON: () => ({}),
+ } as DOMRect;
+ });
+}
+
+function advance(ms: number) {
+ act(() => {
+ vi.advanceTimersByTime(ms);
+ });
+}
+
+/** PointerSensor 的长按激活走 setTimeout;只伪造 setTimeout,React 与 Radix 的调度保持真实。 */
+function pressAndHold(element: HTMLElement) {
+ vi.useFakeTimers({ toFake: ["setTimeout"] });
+ fireEvent.pointerDown(element, { button: 0, isPrimary: true });
+ advance(400);
+}
+
+/** 激活过的指针传感器会在 document 上挂捕获阶段的 click 拦截,detach 后延时 50ms 才摘除,必须走完再交还真实时钟。 */
+function release(element: HTMLElement) {
+ fireEvent.pointerUp(element);
+ advance(100);
+ vi.useRealTimers();
+}
+
+describe("网络规则拖拽可达性", () => {
+ it("表格行保持行/单元格语义,拖拽语义只落在手柄上", () => {
+ renderTable();
+
+ const rows = screen.getAllByRole("row");
+ expect(rows).toHaveLength(4);
+ for (const row of rows.slice(1)) {
+ expect(row).not.toHaveAttribute("tabindex");
+ expect(row).not.toHaveAttribute("aria-roledescription");
+ }
+
+ const handle = handleOf(rows[1], "规则 1");
+ expect(handle).toHaveAttribute("aria-roledescription");
+ const describedBy = handle.getAttribute("aria-describedby") ?? "";
+ expect(document.getElementById(describedBy)?.textContent).toContain("空格");
+ });
+
+ it("键盘拖拽全程用中文播报规则名与位次,而不是规则 ID", async () => {
+ renderTable();
+ stubRowRects();
+ const handle = handleOf(screen.getAllByRole("row")[3], "规则 3");
+
+ fireEvent.keyDown(handle, { code: "Space" });
+ expect(announcement()).toContain("规则 3");
+ expect(announcement()).toContain("第 3 位");
+ expect(announcement()).not.toContain("r3");
+
+ // KeyboardSensor 的 keydown 监听器在 setTimeout 里挂载,激活后必须让出一次事件循环。
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "Space" });
+ expect(announcement()).toContain("已放到第 1 位");
+ });
+
+ it("移动端卡片给出可见手柄,长按手柄拿起卡片", () => {
+ render();
+ const handle = handleOf(screen.getAllByTestId("network-rule-row")[0], "规则 1");
+
+ pressAndHold(handle);
+ expect(announcement()).toContain("规则 1");
+ release(handle);
+ });
+
+ it("长按卡片上的启用开关不会把卡片拿起来,点按即切换启用", () => {
+ render();
+ const toggle = within(screen.getAllByTestId("network-rule-row")[0]).getByRole("switch");
+
+ fireEvent.click(toggle);
+ expect(onToggleEnabled).toHaveBeenCalledWith(RULES[0], false);
+
+ pressAndHold(toggle);
+ expect(announcement()).toBe("");
+ release(toggle);
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx
new file mode 100644
index 000000000..c6c8659d4
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/Feedback.test.tsx
@@ -0,0 +1,180 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { notify } from "@App/pages/components/ui/toast";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleMutationResult, NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+import { extensionEnv } from "@App/app/service/extension/extension_env";
+
+import NetworkRules from ".";
+import { stubNotify } from "./test-helpers";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+});
+afterEach(() => {
+ extensionEnv.inIncognitoContext = false;
+ extensionEnv.incognitoMode = "split";
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(index: number, over: Partial = {}): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`], resourceTypes: ["main_frame"] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ ...over,
+ };
+}
+
+function snapshot(rules: NetworkRule[], over: Partial = {}): NetworkRuleSnapshot {
+ return {
+ state: { schemaVersion: 1, revision: 3, masterEnabled: true, rules, order: rules.map((r) => r.id), ...over },
+ apply: { state: "applied", revision: 3, appliedAt: 1 },
+ };
+}
+
+/** 存储写入成功但 DNR 应用失败:这一条既不是纯成功也不是纯失败。 */
+function applyFailed(current: NetworkRuleSnapshot, order?: string[]): NetworkRuleMutationResult {
+ return {
+ state: { ...current.state, revision: current.state.revision + 1, order: order ?? current.state.order },
+ apply: {
+ state: "error",
+ code: "dnr_apply_failed",
+ desiredRevision: current.state.revision + 1,
+ message: "Rule limit exceeded",
+ },
+ outcome: "applied",
+ } as NetworkRuleMutationResult;
+}
+
+function clientFor(current: NetworkRuleSnapshot, overrides: Partial = {}): NetworkRuleClient {
+ return {
+ getState: vi.fn().mockResolvedValue(current),
+ createRule: vi.fn(),
+ updateRule: vi.fn(),
+ deleteRules: vi.fn(),
+ setRulesEnabled: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ reorderRules: vi.fn(),
+ retryApply: vi.fn(),
+ ...overrides,
+ } as unknown as NetworkRuleClient;
+}
+
+function renderPage(client: NetworkRuleClient) {
+ return renderWithThemeRouter(
+
+ } />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+}
+
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+async function openRowMenu(row: HTMLElement) {
+ const trigger = within(row).getByRole("button", { name: "更多操作" });
+ fireEvent.pointerDown(trigger, { button: 0 });
+ fireEvent.click(trigger);
+ await flush();
+}
+
+describe("网络规则的成败反馈", () => {
+ it("排序保存成功但浏览器未接受时只报失败,不同时弹成功", async () => {
+ const current = snapshot([rule(1), rule(2)]);
+ const client = clientFor(current, {
+ reorderRules: vi.fn(async ({ order }: { order: string[] }) => applyFailed(current, order)),
+ } as unknown as Partial);
+ renderPage(client);
+ expect(await screen.findByText("规则 2")).toBeInTheDocument();
+
+ await openRowMenu(screen.getAllByTestId("network-rule-row")[1]);
+ fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" }));
+ await flush();
+
+ expect(client.reorderRules).toHaveBeenCalled();
+ expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。");
+ expect(notify.success).not.toHaveBeenCalled();
+ });
+
+ it("删除保存成功但浏览器未接受时只报失败,不同时弹成功", async () => {
+ const current = snapshot([rule(1)]);
+ const client = clientFor(current, {
+ deleteRules: vi.fn().mockResolvedValue(applyFailed(current)),
+ } as unknown as Partial);
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ await openRowMenu(screen.getAllByTestId("network-rule-row")[0]);
+ fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: "删除规则" }));
+ await flush();
+
+ expect(client.deleteRules).toHaveBeenCalled();
+ expect(notify.error).toHaveBeenCalledWith("规则已保存,但浏览器规则未能更新。");
+ expect(notify.success).not.toHaveBeenCalled();
+ });
+});
+
+describe("网络规则列表页的状态指示", () => {
+ it("总开关关闭时列表页自己说明规则已暂停", async () => {
+ renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false })));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+ expect(screen.getByText("已暂停")).toBeInTheDocument();
+ expect(screen.queryByText("已生效")).toBeNull();
+ });
+
+ it("规则应用失败时状态显示未生效", async () => {
+ const current = snapshot([rule(1)]);
+ current.apply = { state: "error", code: "dnr_apply_failed", desiredRevision: 3, message: "Rule limit exceeded" };
+ renderPage(clientFor(current));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+ expect(screen.getByText("未生效")).toBeInTheDocument();
+ });
+
+ it("一切正常时状态显示已生效", async () => {
+ renderPage(clientFor(snapshot([rule(1)])));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+ expect(screen.getByText("已生效")).toBeInTheDocument();
+ });
+
+ it("隐身上下文照常渲染规则列表与总开关,且没有隐身提示", async () => {
+ extensionEnv.inIncognitoContext = true;
+ renderPage(clientFor(snapshot([rule(1)])));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+ expect(screen.getByRole("switch", { name: "总开关" })).toBeInTheDocument();
+ expect(screen.queryByText(/请在普通窗口中管理网络规则/)).toBeNull();
+ });
+});
+
+describe("网络规则匹配测试在总开关关闭时", () => {
+ it("照常给出命中,并说明规则当前被总开关暂停", async () => {
+ renderPage(clientFor(snapshot([rule(1)], { masterEnabled: false })));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "测试匹配" }));
+ fireEvent.change(await screen.findByLabelText("网址"), { target: { value: "https://s1.example.com/page" } });
+
+ expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1);
+ expect(screen.getByTestId("match-test-outcome")).not.toHaveTextContent("无命中");
+ expect(within(screen.getByRole("dialog")).getByText(/总开关已关闭/)).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.test.tsx b/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.test.tsx
new file mode 100644
index 000000000..c8dcfdcbb
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.test.tsx
@@ -0,0 +1,90 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { cspRemovalAction, type NetworkRule, type NetworkRuleAction } from "@App/app/repo/network_rule";
+import type { NetworkRuleCondition } from "@App/app/repo/network_rule";
+import MatchTestDialog from "./MatchTestDialog";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => mockMatchMedia());
+afterEach(cleanup);
+
+function rule(id: string, condition: NetworkRuleCondition, action: NetworkRuleAction): NetworkRule {
+ return { id, name: id, enabled: true, condition, action, createdAt: 1, updatedAt: 1 };
+}
+
+const mainFrameGithub: NetworkRuleCondition = { requestDomains: ["github.com"], resourceTypes: ["main_frame"] };
+const mainFrameAll: NetworkRuleCondition = { urlFilter: "*", resourceTypes: ["main_frame"] };
+
+function open(rules: NetworkRule[]) {
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText("网址"), { target: { value: "https://github.com/scriptscat" } });
+}
+
+function hitRows() {
+ return screen.getAllByTestId("match-test-hit").map((row) => row.textContent ?? "");
+}
+
+describe("测试匹配对话框", () => {
+ it("按列表顺序列出命中规则,并标出被靠前 block 短路的规则及原因", () => {
+ open([
+ rule("移除 GitHub 的 CSP", mainFrameGithub, cspRemovalAction()),
+ rule("不相关", { requestDomains: ["example.com"], resourceTypes: ["main_frame"] }, { type: "block" }),
+ rule("屏蔽统计上报", mainFrameAll, { type: "block" }),
+ ]);
+
+ const rows = hitRows();
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toContain("移除 GitHub 的 CSP");
+ expect(rows[0]).toContain("#1");
+ expect(rows[0]).toContain("请求已被 #3 屏蔽,不会执行");
+ expect(rows[1]).toContain("屏蔽统计上报");
+ expect(rows[1]).toContain("#3");
+ expect(screen.getByTestId("match-test-outcome")).toHaveTextContent("屏蔽");
+ });
+
+ it("标出被靠前 allow 压过的规则及原因", () => {
+ open([rule("放行调试接口", mainFrameGithub, { type: "allow" }), rule("屏蔽全部", mainFrameAll, { type: "block" })]);
+
+ const rows = hitRows();
+ expect(rows[1]).toContain("顺序更靠前的 #1 已决定结果,不会执行");
+ expect(screen.getByTestId("match-test-outcome")).toHaveTextContent("放行");
+ });
+
+ it("资源类型改变会改变命中结果", () => {
+ open([rule("只改脚本请求", { requestDomains: ["github.com"], resourceTypes: ["script"] }, { type: "block" })]);
+ expect(screen.queryAllByTestId("match-test-hit")).toHaveLength(0);
+
+ fireEvent.click(screen.getByLabelText("资源类型"));
+ fireEvent.click(screen.getByRole("option", { name: "脚本" }));
+ expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1);
+ });
+
+ it("完全是前端模拟,不读取浏览器的实际命中记录", () => {
+ // ui project 用 isolate: false,chrome mock 由同一 worker 内的所有测试文件共享,用完必须摘掉。
+ const getMatchedRules = vi.fn();
+ const dnr = chrome.declarativeNetRequest as unknown as Record;
+ dnr.getMatchedRules = getMatchedRules;
+ try {
+ open([rule("屏蔽全部", mainFrameAll, { type: "block" })]);
+ expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1);
+ expect(getMatchedRules).not.toHaveBeenCalled();
+ } finally {
+ delete dnr.getMatchedRules;
+ }
+ });
+
+ it("地址不合法时提示补全 http(s) 地址且不给结果", () => {
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText("网址"), { target: { value: "github.com" } });
+ expect(screen.getByRole("status")).toHaveTextContent("请输入完整的 http(s) 地址。");
+ expect(screen.queryByTestId("match-test-outcome")).toBeNull();
+ });
+
+ it("没有规则命中时说明当前规则都不匹配", () => {
+ open([rule("别处的规则", { requestDomains: ["example.com"], resourceTypes: ["main_frame"] }, { type: "block" })]);
+ expect(screen.getByTestId("match-test-outcome")).toHaveTextContent("无命中");
+ expect(within(screen.getByTestId("match-test-outcome")).queryByText("屏蔽")).toBeNull();
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.tsx b/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.tsx
new file mode 100644
index 000000000..2bed790fb
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/MatchTestDialog.tsx
@@ -0,0 +1,197 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Info, PauseCircle } from "lucide-react";
+import type { NetworkRule } from "@App/app/repo/network_rule";
+import { NETWORK_RULE_RESOURCE_TYPES, type NetworkRuleResourceType } from "@App/pkg/utils/network_rule_condition";
+import { simulateNetworkRules, type NetworkRuleHit, type NetworkRuleOutcome } from "@App/pkg/utils/network_rule_match";
+import { Badge } from "@App/pages/components/ui/badge";
+import { Button } from "@App/pages/components/ui/button";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@App/pages/components/ui/dialog";
+import { Input } from "@App/pages/components/ui/input";
+import { Label } from "@App/pages/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select";
+import { cn } from "@App/pkg/utils/cn";
+import { useActionLabels, useResourceTypeLabels } from "./RuleParts";
+import { ruleDomains, isAllSitesCondition } from "./rules";
+
+function useOutcomeLabels(): Record {
+ const { t } = useTranslation();
+ return {
+ none: t("tools:network_rules_outcome_none"),
+ blocked: t("tools:network_rules_outcome_blocked"),
+ redirected: t("tools:network_rules_outcome_redirected"),
+ allowed: t("tools:network_rules_outcome_allowed"),
+ modified: t("tools:network_rules_outcome_modified"),
+ };
+}
+
+function HitReason({ hit }: { hit: NetworkRuleHit }) {
+ const { t } = useTranslation();
+ const position = hit.causedBy ?? 0;
+ if (hit.status === "blocked") return <>{t("tools:network_rules_hit_blocked", { position })}>;
+ if (hit.status === "overridden") return <>{t("tools:network_rules_hit_overridden", { position })}>;
+ if (hit.status === "allowed") return <>{t("tools:network_rules_hit_allowed", { position })}>;
+ return <>{t("tools:network_rules_hit_applied")}>;
+}
+
+function scopeText(rule: NetworkRule, allSitesLabel: string): string {
+ return isAllSitesCondition(rule.condition) ? allSitesLabel : ruleDomains(rule).join(" · ");
+}
+
+export default function MatchTestDialog({
+ open,
+ rules,
+ paused,
+ onOpenChange,
+}: {
+ open: boolean;
+ /** 完整的有序规则列表:结果里的 #N 就是列表页的位次。 */
+ rules: NetworkRule[];
+ /** 总开关关闭时仍照常模拟:否则「无命中」会把暂停误报成规则写错。 */
+ paused?: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ const actionLabels = useActionLabels();
+ const resourceLabels = useResourceTypeLabels();
+ const outcomeLabels = useOutcomeLabels();
+ const [url, setUrl] = useState("");
+ const [resourceType, setResourceType] = useState("main_frame");
+
+ const simulation = useMemo(
+ () => (url.trim() ? simulateNetworkRules(rules, { url, resourceType }) : undefined),
+ [rules, url, resourceType]
+ );
+
+ const outcomeDetail = () => {
+ if (!simulation) return "";
+ switch (simulation.outcome) {
+ case "blocked":
+ return t("tools:network_rules_outcome_blocked_detail");
+ case "redirected":
+ return t("tools:network_rules_outcome_redirected_detail", { url: simulation.redirectUrl ?? "" });
+ case "allowed":
+ return t("tools:network_rules_outcome_allowed_detail");
+ case "modified":
+ return t("tools:network_rules_outcome_modified_detail", {
+ count: simulation.hits.filter((hit) => hit.status === "applied").length,
+ });
+ default:
+ return t("tools:network_rules_outcome_none_detail");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx b/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx
new file mode 100644
index 000000000..0c20310f4
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RowRenderCost.test.tsx
@@ -0,0 +1,115 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, screen } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+
+import NetworkRules from ".";
+import { stubNotify } from "./test-helpers";
+
+const PAGE_ROWS = 20;
+
+const reads = { count: 0 };
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+ reads.count = 0;
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+/**
+ * 行渲染次数的计数器:updatedAt 只被行内的「最近修改时间」读,一次行渲染恰好读一次,
+ * 页面其余部分都不碰它。用测试自己的数据对象计数而不是 mock 模块,是因为 ui 项目
+ * isolate:false 共享模块缓存,模块级 mock 会随文件调度顺序时灵时不灵。
+ * 量的是行重算的次数本身,不是某个组件有没有被 memo 包住。
+ */
+function rule(index: number): NetworkRule {
+ const value: NetworkRule = {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ };
+ return Object.defineProperty(value, "updatedAt", {
+ enumerable: true,
+ get() {
+ reads.count += 1;
+ return 1;
+ },
+ });
+}
+
+function clientFor(rules: NetworkRule[]): NetworkRuleClient {
+ const snapshot: NetworkRuleSnapshot = {
+ state: { schemaVersion: 1, revision: 3, masterEnabled: true, rules, order: rules.map((r) => r.id) },
+ apply: { state: "applied", revision: 3, appliedAt: 1 },
+ };
+ return {
+ getState: vi.fn().mockResolvedValue(snapshot),
+ createRule: vi.fn(),
+ updateRule: vi.fn(),
+ deleteRules: vi.fn(),
+ setRulesEnabled: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ reorderRules: vi.fn(),
+ retryApply: vi.fn(),
+ } as unknown as NetworkRuleClient;
+}
+
+async function renderRows() {
+ renderWithThemeRouter(
+
+ rule(index)))} />}
+ />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+ expect(await screen.findByText("规则 0")).toBeInTheDocument();
+ expect(screen.getAllByTestId("network-rule-row")).toHaveLength(PAGE_ROWS);
+}
+
+describe("网络规则列表页的行渲染开销", () => {
+ it("首屏 20 行各渲染一次,不重复渲染", async () => {
+ await renderRows();
+
+ expect(reads.count).toBe(PAGE_ROWS);
+ });
+
+ it("搜索按键不改变可见行时,一行也不重算", async () => {
+ await renderRows();
+ // 第一个字符会把手柄从可拖变成禁用,整页行本来就该重算一次;量的是后续按键。
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规" } });
+ reads.count = 0;
+
+ // 「规则」是所有行的公共前缀:可见行集合与顺序都不变,变的只是父级重渲染这件事本身。
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则" } });
+
+ expect(screen.getAllByTestId("network-rule-row")).toHaveLength(PAGE_ROWS);
+ expect(reads.count).toBe(0);
+ });
+
+ it("勾选一行只重算那一行,其余 19 行不受影响", async () => {
+ await renderRows();
+ reads.count = 0;
+
+ // 按 aria-label 取:20 行的表上整页 role 扫描要给 21 个复选框各算一遍可访问名,约 46ms。
+ fireEvent.click(screen.getByLabelText("选择 规则 7"));
+
+ expect(reads.count).toBe(1);
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx
new file mode 100644
index 000000000..576be8e37
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleCards.tsx
@@ -0,0 +1,166 @@
+import { memo, useMemo } from "react";
+import { GripVertical } from "lucide-react";
+import type { DragEndEvent } from "@dnd-kit/core";
+import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { NetworkRule } from "@App/app/repo/network_rule";
+import { Switch } from "@App/pages/components/ui/switch";
+import { cn } from "@App/pkg/utils/cn";
+import { useDragAccessibility } from "./dragAccessibility";
+import {
+ ActionBadge,
+ RuleName,
+ RuleRowMenu,
+ ScopeChips,
+ useRuleRowLabels,
+ type RuleRowActions,
+ type RuleRowLabels,
+} from "./RuleParts";
+
+// 长按手柄进入拖拽;delay 之内的移动仍按滚动处理。
+const LONG_PRESS = { delay: 300, tolerance: 8 };
+
+export type RuleCardsProps = RuleRowActions & {
+ rules: NetworkRule[];
+ positionOf: (rule: NetworkRule) => number;
+ total: number;
+ dragDisabled: boolean;
+ busy: boolean;
+ onToggleEnabled: (rule: NetworkRule, enabled: boolean) => void;
+ onDragEnd: (activeId: string, overId: string) => void;
+};
+
+export default function RuleCards({
+ rules,
+ positionOf,
+ total,
+ dragDisabled,
+ busy,
+ onToggleEnabled,
+ onDragEnd,
+ ...moveHandlers
+}: RuleCardsProps) {
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: LONG_PRESS }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ );
+ const ids = useMemo(() => rules.map((rule) => rule.id), [rules]);
+ const labels = useRuleRowLabels();
+ const a11y = useDragAccessibility(rules, positionOf, total);
+
+ const handleDragEnd = ({ active, over }: DragEndEvent) => {
+ if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`);
+ };
+
+ return (
+
+
+
+ {rules.map((rule) => (
+
+ ))}
+
+
+
+ );
+}
+
+type RuleCardProps = RuleRowActions & {
+ rule: NetworkRule;
+ position: number;
+ total: number;
+ dragDisabled: boolean;
+ busy: boolean;
+ labels: RuleRowLabels;
+ onToggleEnabled: (rule: NetworkRule, enabled: boolean) => void;
+};
+
+/** 与 RuleTable 的行同理:只有拖拽接线留在外层,花钱的部分放进 memo 边界内。 */
+function SortableRuleCard({ rule, dragDisabled, labels, ...bodyProps }: RuleCardProps) {
+ const { setNodeRef, setActivatorNodeRef, listeners, attributes, transform, transition, isDragging } = useSortable({
+ id: rule.id,
+ disabled: dragDisabled,
+ });
+
+ return (
+
+ {/* touch-none 而不是 touch-manipulation:后者仍允许浏览器在拖拽途中把手势收回去做滚动。 */}
+
+
+
+ );
+}
+
+const RuleCardBody = memo(function RuleCardBody({
+ rule,
+ position,
+ total,
+ busy,
+ labels,
+ onToggleEnabled,
+ ...moveHandlers
+}: Omit) {
+ return (
+
+
+
onToggleEnabled(rule, checked)}
+ />
+
+
+
+ {position}
+
+
+
+
+ );
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleForm.test.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleForm.test.tsx
new file mode 100644
index 000000000..e2dcc4ed4
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleForm.test.tsx
@@ -0,0 +1,72 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { parseRuleDomains } from "@App/pkg/utils/network_rule_condition";
+import RuleForm, { type RuleFormState } from "./RuleForm";
+import { emptyActionDraft } from "./templates";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => mockMatchMedia());
+afterEach(cleanup);
+
+function renderForm(over: Partial = {}) {
+ const state: RuleFormState = {
+ websites: "example.com",
+ allSites: false,
+ name: "",
+ draft: emptyActionDraft("block"),
+ resourceTypes: [],
+ requestMethods: [],
+ excludedWebsites: "",
+ tryUrl: "",
+ ...over,
+ };
+ render(
+ {}}
+ onBlurScope={() => {}}
+ onChangeTemplate={() => {}}
+ onCancel={() => {}}
+ onSubmit={() => {}}
+ />
+ );
+ fireEvent.click(screen.getByRole("button", { name: "高级选项" }));
+}
+
+describe("规则表单的高级区", () => {
+ it("资源类型显示译名而非 DNR 标识符", () => {
+ renderForm();
+
+ expect(screen.getByText("主框架")).toBeInTheDocument();
+ expect(screen.getByText("XHR")).toBeInTheDocument();
+ expect(screen.queryByText("main_frame")).not.toBeInTheDocument();
+ expect(screen.queryByText("xmlhttprequest")).not.toBeInTheDocument();
+ });
+
+ it("请求方法显示大写的 HTTP 动词而非小写标识符", () => {
+ renderForm();
+
+ expect(screen.getByText("GET")).toBeInTheDocument();
+ expect(screen.queryByText("get")).not.toBeInTheDocument();
+ });
+
+ it("勾选框的无障碍名与可见文本一致,勾选状态仍来自 state", () => {
+ renderForm({ resourceTypes: ["main_frame"], requestMethods: ["post"] });
+
+ expect(screen.getByRole("checkbox", { name: "主框架" })).toBeChecked();
+ expect(screen.getByRole("checkbox", { name: "子框架" })).not.toBeChecked();
+ expect(screen.getByRole("checkbox", { name: "POST" })).toBeChecked();
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleForm.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleForm.tsx
new file mode 100644
index 000000000..170423245
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleForm.tsx
@@ -0,0 +1,662 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle, ChevronDown, Plus, X } from "lucide-react";
+import {
+ CSP_RESPONSE_HEADERS,
+ MAX_RULE_HEADERS,
+ X_FRAME_OPTIONS_HEADER,
+ MAX_RULE_NAME_LENGTH,
+ type NetworkRuleAction,
+ type NetworkRuleActionType,
+ type NetworkRuleCondition,
+} from "@App/app/repo/network_rule";
+import {
+ NETWORK_RULE_REQUEST_METHODS,
+ NETWORK_RULE_RESOURCE_TYPES,
+ type NetworkRuleRequestMethod,
+ type NetworkRuleResourceType,
+ type RuleDomainMessageKey,
+ type RuleDomainParseResult,
+} from "@App/pkg/utils/network_rule_condition";
+import { matchRuleUrl } from "@App/pkg/utils/network_rule_match";
+import { Badge } from "@App/pages/components/ui/badge";
+import { Button } from "@App/pages/components/ui/button";
+import { Checkbox } from "@App/pages/components/ui/checkbox";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@App/pages/components/ui/collapsible";
+import { Input } from "@App/pages/components/ui/input";
+import { Label } from "@App/pages/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select";
+import { Textarea } from "@App/pages/components/ui/textarea";
+import { cn } from "@App/pkg/utils/cn";
+import { ACTION_TONES, useActionLabels, useResourceTypeLabels } from "./RuleParts";
+import {
+ ACTION_TYPE_BY_TEMPLATE,
+ buildAction,
+ editsHeaderList,
+ editsRequestHeaders,
+ USER_AGENT_PRESETS,
+ type ActionDraft,
+ type ActionDraftErrors,
+ type DraftErrorKey,
+ type HeaderDraft,
+ type RuleTemplateId,
+} from "./templates";
+
+export type RuleFormState = {
+ websites: string;
+ allSites: boolean;
+ name: string;
+ draft: ActionDraft;
+ resourceTypes: NetworkRuleResourceType[];
+ requestMethods: NetworkRuleRequestMethod[];
+ excludedWebsites: string;
+ tryUrl: string;
+};
+
+type RuleFormProps = {
+ template: RuleTemplateId;
+ templateLabel: string;
+ state: RuleFormState;
+ scope: RuleDomainParseResult;
+ excluded: RuleDomainParseResult;
+ actionErrors: ActionDraftErrors;
+ condition: NetworkRuleCondition;
+ touched: boolean;
+ saving: boolean;
+ canSave: boolean;
+ submitError: string;
+ onChange: (patch: Partial) => void;
+ onBlurScope: () => void;
+ onChangeTemplate: () => void;
+ onCancel: () => void;
+ onSubmit: () => void;
+};
+
+const HEADER_OPERATION_ORDER = ["set", "append", "remove"] as const;
+
+/** 域名与草稿的错误文案都用字面量 key 调用 t(),i18n-usage 的静态校验才能覆盖到。 */
+function useDomainErrorLabels(): Record {
+ const { t } = useTranslation();
+ return {
+ domain_required: t("tools:network_rules_error_domain_required"),
+ domain_invalid: t("tools:network_rules_error_domain_invalid"),
+ domain_credentials: t("tools:network_rules_error_domain_credentials"),
+ domain_wildcard: t("tools:network_rules_error_domain_wildcard"),
+ domain_single_label: t("tools:network_rules_error_domain_single_label"),
+ domain_too_long: t("tools:network_rules_error_domain_too_long"),
+ domain_count_invalid: t("tools:network_rules_error_domain_count_invalid"),
+ };
+}
+
+function useDraftErrorLabels(): Record {
+ const { t } = useTranslation();
+ return {
+ required: "",
+ header_name_invalid: t("tools:network_rules_error_header_name_invalid"),
+ header_denied: t("tools:network_rules_error_header_denied"),
+ header_value_required: t("tools:network_rules_error_header_value_required"),
+ redirect_url_invalid: t("tools:network_rules_error_redirect_url_invalid"),
+ };
+}
+
+/**
+ * 「试一试」只回答匹配与否,用户还是不知道匹配之后会发生什么——而那正是这一栏要确认的。
+ * 文案由 buildAction 的结果生成,与保存下去的动作是同一份数据。
+ */
+function useActionEffectLabel(action: NetworkRuleAction): string {
+ const { t } = useTranslation();
+ switch (action.type) {
+ case "removeResponseHeaders":
+ return t("tools:network_rules_try_effect_remove_response_headers", { count: action.headers.length });
+ case "modifyRequestHeaders":
+ return t("tools:network_rules_try_effect_modify_request_headers", { count: action.headers.length });
+ case "modifyResponseHeaders":
+ return t("tools:network_rules_try_effect_modify_response_headers", { count: action.headers.length });
+ case "block":
+ return t("tools:network_rules_try_effect_block");
+ case "redirect":
+ return t("tools:network_rules_try_effect_redirect", { url: action.url });
+ case "allow":
+ return t("tools:network_rules_try_effect_allow");
+ }
+}
+
+function useHeaderOperationLabels(): Record<(typeof HEADER_OPERATION_ORDER)[number], string> {
+ const { t } = useTranslation();
+ return {
+ set: t("tools:network_rules_header_op_set"),
+ append: t("tools:network_rules_header_op_append"),
+ remove: t("tools:network_rules_header_op_remove"),
+ };
+}
+
+export default function RuleForm({
+ template,
+ templateLabel,
+ state,
+ scope,
+ excluded,
+ actionErrors,
+ condition,
+ touched,
+ saving,
+ canSave,
+ submitError,
+ onChange,
+ onBlurScope,
+ onChangeTemplate,
+ onCancel,
+ onSubmit,
+}: RuleFormProps) {
+ const { t } = useTranslation();
+ const domainErrors = useDomainErrorLabels();
+ const draftErrors = useDraftErrorLabels();
+ const resourceTypeLabels = useResourceTypeLabels();
+ const showScopeErrors = touched && !state.allSites && state.websites.trim() !== "";
+ const match = useMemo(
+ () => (state.tryUrl.trim() ? matchRuleUrl(condition, state.tryUrl) : undefined),
+ [condition, state.tryUrl]
+ );
+ const action = useMemo(() => buildAction(template, state.draft), [template, state.draft]);
+ const effect = useActionEffectLabel(action);
+
+ return (
+
+ );
+}
+
+function CheckboxGrid({
+ label,
+ options,
+ optionLabel,
+ selected,
+ disabled,
+ onToggle,
+}: {
+ label: string;
+ options: readonly T[];
+ optionLabel: (option: T) => string;
+ selected: T[];
+ disabled: boolean;
+ onToggle: (value: T, checked: boolean) => void;
+}) {
+ return (
+
+
+
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+function ActionFields({
+ template,
+ state,
+ errors,
+ errorLabels,
+ saving,
+ onChange,
+}: {
+ template: RuleTemplateId;
+ state: RuleFormState;
+ errors: ActionDraftErrors;
+ errorLabels: Record;
+ saving: boolean;
+ onChange: (patch: Partial) => void;
+}) {
+ const { t } = useTranslation();
+ const actionLabels = useActionLabels();
+ const { draft } = state;
+ const patchDraft = (patch: Partial) => onChange({ draft: { ...draft, ...patch } });
+ const actionError = errors.action && errorLabels[errors.action];
+
+ if (template === "csp") {
+ const cspTone = ACTION_TONES[ACTION_TYPE_BY_TEMPLATE.csp];
+ return (
+
+
+
+ {CSP_RESPONSE_HEADERS.map((header) => (
+
+ {header}
+
+ ))}
+ {draft.includeXFrameOptions && (
+
+ {X_FRAME_OPTIONS_HEADER}
+
+ )}
+
+
+
+ );
+ }
+
+ if (template === "userAgent") {
+ return (
+
+
+
+ {[
+ { label: t("tools:network_rules_ua_preset_iphone"), value: USER_AGENT_PRESETS.iphone },
+ { label: t("tools:network_rules_ua_preset_android"), value: USER_AGENT_PRESETS.android },
+ { label: t("tools:network_rules_ua_preset_windows"), value: USER_AGENT_PRESETS.windows },
+ ].map((preset) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (template === "referer") {
+ return (
+
+
+
+ {draft.refererOperation === "set" && (
+ patchDraft({ referer: event.target.value })}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+ {template === "custom" && (
+
+
+
+
+ )}
+
+ {draft.actionType === "redirect" && (
+
+
+
patchDraft({ redirectUrl: event.target.value })}
+ />
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+ )}
+
+ {editsHeaderList(draft.actionType) && (
+
patchDraft({ headers })}
+ />
+ )}
+
+ );
+}
+
+function HeaderRows({
+ actionType,
+ headers,
+ errors,
+ errorLabels,
+ saving,
+ onChange,
+}: {
+ actionType: NetworkRuleActionType;
+ headers: HeaderDraft[];
+ errors: (DraftErrorKey | undefined)[];
+ errorLabels: Record;
+ saving: boolean;
+ onChange: (headers: HeaderDraft[]) => void;
+}) {
+ const { t } = useTranslation();
+ const operationLabels = useHeaderOperationLabels();
+ const withValues = actionType !== "removeResponseHeaders";
+ const patchRow = (index: number, patch: Partial) =>
+ onChange(headers.map((row, current) => (current === index ? { ...row, ...patch } : row)));
+
+ return (
+
+
+ {/* 黑名单是这一栏的固有约束,等用户敲完再报错等于让他白填一次。 */}
+ {editsRequestHeaders(actionType) && (
+
{t("tools:network_rules_header_denied_note")}
+ )}
+ {headers.map((row, index) => {
+ const error = errors[index];
+ const message = error ? errorLabels[error] : "";
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleParts.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleParts.tsx
new file mode 100644
index 000000000..0db6f37f2
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleParts.tsx
@@ -0,0 +1,224 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle, MoreHorizontal } from "lucide-react";
+import type { NetworkRule, NetworkRuleActionType } from "@App/app/repo/network_rule";
+import type { NetworkRuleResourceType } from "@App/pkg/utils/network_rule_condition";
+import { Badge } from "@App/pages/components/ui/badge";
+import { Button } from "@App/pages/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@App/pages/components/ui/dropdown-menu";
+import { semTime } from "@App/locales/relative-date";
+import { cn } from "@App/pkg/utils/cn";
+import { isAllSitesCondition, ruleDomains } from "./rules";
+
+const VISIBLE_DOMAINS = 2;
+
+/** 每个动作名都用字面量 key 调用 t(),i18n-usage 的静态校验才能覆盖到。 */
+export function useActionLabels(): Record {
+ const { t } = useTranslation();
+ return useMemo(
+ () => ({
+ removeResponseHeaders: t("tools:network_rule_action_remove_response_headers"),
+ modifyRequestHeaders: t("tools:network_rule_action_modify_request_headers"),
+ modifyResponseHeaders: t("tools:network_rule_action_modify_response_headers"),
+ block: t("tools:network_rule_action_block"),
+ redirect: t("tools:network_rule_action_redirect"),
+ allow: t("tools:network_rule_action_allow"),
+ }),
+ [t]
+ );
+}
+
+/**
+ * 资源类型的译名:编辑抽屉的高级区与匹配测试对话框读同一份,避免一处显示译名、另一处显示
+ * DNR 标识符。每个类型都用字面量 key 调用 t(),i18n-usage 的静态校验才能覆盖到。
+ */
+export function useResourceTypeLabels(): Record {
+ const { t } = useTranslation();
+ return useMemo(
+ () => ({
+ main_frame: t("tools:network_rule_resource_main_frame"),
+ sub_frame: t("tools:network_rule_resource_sub_frame"),
+ stylesheet: t("tools:network_rule_resource_stylesheet"),
+ script: t("tools:network_rule_resource_script"),
+ image: t("tools:network_rule_resource_image"),
+ font: t("tools:network_rule_resource_font"),
+ object: t("tools:network_rule_resource_object"),
+ xmlhttprequest: t("tools:network_rule_resource_xmlhttprequest"),
+ ping: t("tools:network_rule_resource_ping"),
+ csp_report: t("tools:network_rule_resource_csp_report"),
+ media: t("tools:network_rule_resource_media"),
+ websocket: t("tools:network_rule_resource_websocket"),
+ other: t("tools:network_rule_resource_other"),
+ }),
+ [t]
+ );
+}
+
+/**
+ * 与规则无关的行内文案在表格级取一次就够:每行各自 useTranslation 会让 20 行的一次渲染
+ * 跑上百次 t(),而这些字串对所有行都一样。带规则名的几条做成函数,标识同样稳定,
+ * 于是行内容可以整块 memo 掉。切换语言时 t 换标识,整个对象随之失效。
+ */
+export type RuleRowLabels = {
+ actions: Record;
+ allWebsites: string;
+ menu: string;
+ edit: string;
+ moveTop: string;
+ moveBottom: string;
+ moveTo: string;
+ remove: string;
+ modifiedAt: (time: string) => string;
+ dragHandle: (name: string) => string;
+ selectRule: (name: string) => string;
+ enableRule: (name: string) => string;
+};
+
+export function useRuleRowLabels(): RuleRowLabels {
+ const { t } = useTranslation();
+ const actions = useActionLabels();
+ return useMemo(
+ () => ({
+ actions,
+ allWebsites: t("tools:network_rules_all_websites"),
+ menu: t("tools:network_rules_row_menu"),
+ edit: t("tools:network_rules_edit"),
+ moveTop: t("tools:network_rules_move_top"),
+ moveBottom: t("tools:network_rules_move_bottom"),
+ moveTo: t("tools:network_rules_move_to"),
+ remove: t("tools:network_rules_delete"),
+ modifiedAt: (time: string) => t("tools:network_rules_modified_at", { time }),
+ dragHandle: (name: string) => t("tools:network_rules_drag_handle", { name }),
+ selectRule: (name: string) => t("tools:network_rules_select_rule", { name }),
+ enableRule: (name: string) => t("tools:network_rules_enable_rule", { name }),
+ }),
+ [t, actions]
+ );
+}
+
+/**
+ * 动作类型的固定色相,取自 `--label-*` 令牌族(明暗自动切换,见 docs/references/design-tokens.md)。
+ * 与 NameAvatar 的名称哈希不同,这里是一一对应的分类色:同一条规则的动作徽标、模板卡图标与表单里
+ * 的动作字段读同一份映射,列表与编辑两处才不会给同一个动作两种颜色。
+ * 留出 purple 不用:设计稿把它留给「规则由脚本创建」的来源标记。
+ */
+export const ACTION_TONES: Record = {
+ removeResponseHeaders: "bg-label-amber-bg text-label-amber-fg",
+ modifyRequestHeaders: "bg-label-teal-bg text-label-teal-fg",
+ modifyResponseHeaders: "bg-label-indigo-bg text-label-indigo-fg",
+ block: "bg-label-rose-bg text-label-rose-fg",
+ redirect: "bg-label-blue-bg text-label-blue-fg",
+ allow: "bg-label-green-bg text-label-green-fg",
+};
+
+export function ActionBadge({
+ action,
+ label,
+ className,
+}: {
+ action: NetworkRuleActionType;
+ label: string;
+ className?: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+export function ScopeChips({ rule, allSitesLabel }: { rule: NetworkRule; allSitesLabel: string }) {
+ if (isAllSitesCondition(rule.condition)) {
+ return (
+
+
+ {allSitesLabel}
+
+ );
+ }
+ const domains = ruleDomains(rule);
+ return (
+
+ {domains.slice(0, VISIBLE_DOMAINS).map((domain) => (
+
+ {domain}
+
+ ))}
+ {domains.length > VISIBLE_DOMAINS && (
+ {`+${domains.length - VISIBLE_DOMAINS}`}
+ )}
+
+ );
+}
+
+export function RuleName({ rule, labels }: { rule: NetworkRule; labels: RuleRowLabels }) {
+ return (
+
+
+ {rule.name}
+
+
+ {labels.modifiedAt(semTime(new Date(rule.updatedAt)))}
+
+
+ );
+}
+
+export type RuleRowActions = {
+ onEdit: (rule: NetworkRule) => void;
+ onDelete: (rule: NetworkRule) => void;
+ onMoveTop: (rule: NetworkRule) => void;
+ onMoveBottom: (rule: NetworkRule) => void;
+ onMoveTo: (rule: NetworkRule) => void;
+};
+
+export function RuleRowMenu({
+ rule,
+ position,
+ total,
+ disabled,
+ labels,
+ onEdit,
+ onDelete,
+ onMoveTop,
+ onMoveBottom,
+ onMoveTo,
+}: RuleRowActions & {
+ rule: NetworkRule;
+ position: number;
+ total: number;
+ disabled: boolean;
+ labels: RuleRowLabels;
+}) {
+ return (
+
+
+
+
+
+ onEdit(rule)}>{labels.edit}
+ onMoveTop(rule)}>
+ {labels.moveTop}
+
+ onMoveBottom(rule)}>
+ {labels.moveBottom}
+
+ onMoveTo(rule)}>
+ {labels.moveTo}
+
+
+ onDelete(rule)}>
+ {labels.remove}
+
+
+
+ );
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleSheet.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleSheet.tsx
new file mode 100644
index 000000000..24b807375
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleSheet.tsx
@@ -0,0 +1,294 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ArrowLeft, Ban, CornerUpRight, FileText, Globe, Link2, ShieldOff, Smartphone } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+import { type NetworkRule, type NetworkRuleAction, type NetworkRuleCondition } from "@App/app/repo/network_rule";
+import type { NetworkRuleServiceError } from "@App/app/service/service_worker/client";
+import { parseRuleDomains } from "@App/pkg/utils/network_rule_condition";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@App/pages/components/ui/alert-dialog";
+import { Button } from "@App/pages/components/ui/button";
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@App/pages/components/ui/sheet";
+import { cn } from "@App/pkg/utils/cn";
+import { ACTION_TONES } from "./RuleParts";
+import RuleForm, { type RuleFormState } from "./RuleForm";
+import { ALL_SITES_URL_FILTER, isAllSitesCondition } from "./rules";
+import {
+ ACTION_TYPE_BY_TEMPLATE,
+ actionDraftFrom,
+ buildAction,
+ CSP_RESOURCE_TYPES,
+ detectTemplate,
+ emptyActionDraft,
+ hasActionDraftError,
+ validateActionDraft,
+ type RuleTemplateId,
+} from "./templates";
+
+export type NetworkRuleFormValue = { name: string; condition: NetworkRuleCondition; action: NetworkRuleAction };
+export type NetworkRuleSaveResult = true | false | NetworkRuleServiceError;
+
+type RuleSheetProps = {
+ open: boolean;
+ rule?: NetworkRule;
+ /** 从空态的场景入口进来时直接落到第二步,跳过选模板。 */
+ initialTemplate?: RuleTemplateId;
+ saving: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (value: NetworkRuleFormValue) => Promise;
+};
+
+const TEMPLATE_ICONS: Record = {
+ csp: ShieldOff,
+ userAgent: Smartphone,
+ referer: Link2,
+ responseHeaders: FileText,
+ block: Ban,
+ redirect: CornerUpRight,
+ custom: Globe,
+};
+
+/** 每个模板名都用字面量 key 调用 t(),i18n-usage 的静态校验才能覆盖到。 */
+export function useTemplateLabels(): Record {
+ const { t } = useTranslation();
+ return {
+ csp: { title: t("tools:network_rule_template_csp"), description: t("tools:network_rule_template_csp_desc") },
+ userAgent: {
+ title: t("tools:network_rule_template_user_agent"),
+ description: t("tools:network_rule_template_user_agent_desc"),
+ },
+ referer: {
+ title: t("tools:network_rule_template_referer"),
+ description: t("tools:network_rule_template_referer_desc"),
+ },
+ responseHeaders: {
+ title: t("tools:network_rule_template_response_headers"),
+ description: t("tools:network_rule_template_response_headers_desc"),
+ },
+ block: { title: t("tools:network_rule_template_block"), description: t("tools:network_rule_template_block_desc") },
+ redirect: {
+ title: t("tools:network_rule_template_redirect"),
+ description: t("tools:network_rule_template_redirect_desc"),
+ },
+ custom: {
+ title: t("tools:network_rule_template_custom"),
+ description: t("tools:network_rule_template_custom_desc"),
+ },
+ };
+}
+
+function initialState(rule: NetworkRule | undefined, template: RuleTemplateId): RuleFormState {
+ const condition = rule?.condition;
+ const allSites = condition !== undefined && isAllSitesCondition(condition);
+ // 编辑既有规则时资源类型一律照搬原值;只有新建才套模板预设,与 chooseTemplate 保持一致。
+ const presetResourceTypes = template === "csp" ? [...CSP_RESOURCE_TYPES] : [];
+ return {
+ websites: (allSites ? undefined : condition?.requestDomains)?.join("\n") ?? "",
+ allSites,
+ name: rule?.name ?? "",
+ draft: rule ? actionDraftFrom(rule.action) : emptyActionDraft(template),
+ resourceTypes: rule ? (condition?.resourceTypes ?? []) : presetResourceTypes,
+ requestMethods: condition?.requestMethods ?? [],
+ excludedWebsites: condition?.excludedRequestDomains?.join("\n") ?? "",
+ tryUrl: "",
+ };
+}
+
+export default function RuleSheet({ open, rule, initialTemplate, saving, onOpenChange, onSave }: RuleSheetProps) {
+ const { t } = useTranslation();
+ const templateLabels = useTemplateLabels();
+ const initial = rule ? detectTemplate(rule.action) : (initialTemplate ?? "csp");
+ const [step, setStep] = useState<"template" | "form">(rule || initialTemplate ? "form" : "template");
+ const [template, setTemplate] = useState(initial);
+ const [state, setState] = useState(() => initialState(rule, initial));
+ const [touched, setTouched] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [pendingValue, setPendingValue] = useState();
+
+ const scope = useMemo(() => parseRuleDomains(state.websites), [state.websites]);
+ const excluded = useMemo(() => parseRuleDomains(state.excludedWebsites), [state.excludedWebsites]);
+ const actionErrors = useMemo(() => validateActionDraft(template, state.draft), [template, state.draft]);
+
+ const scopeInvalid = !state.allSites && (scope.errors.length > 0 || !state.websites.trim());
+ const excludedInvalid = state.excludedWebsites.trim() !== "" && excluded.errors.length > 0;
+ const canSave = !scopeInvalid && !excludedInvalid && !hasActionDraftError(actionErrors);
+
+ const change = (patch: Partial) => setState((current) => ({ ...current, ...patch }));
+
+ // 更换类型只重置动作字段与资源类型:应用范围、排除域名与名称是与场景无关的输入,按设计稿保留。
+ const chooseTemplate = (next: RuleTemplateId) => {
+ setTemplate(next);
+ setStep("form");
+ setSubmitError("");
+ if (next === template && step === "form") return;
+ change({
+ draft: emptyActionDraft(next),
+ resourceTypes: next === "csp" ? [...CSP_RESOURCE_TYPES] : [],
+ });
+ };
+
+ const condition = useMemo(
+ () => ({
+ ...(state.allSites ? { urlFilter: ALL_SITES_URL_FILTER } : { requestDomains: scope.domains }),
+ ...(excluded.domains.length > 0 && !state.allSites ? { excludedRequestDomains: excluded.domains } : {}),
+ ...(state.resourceTypes.length > 0 ? { resourceTypes: state.resourceTypes } : {}),
+ ...(state.requestMethods.length > 0 ? { requestMethods: state.requestMethods } : {}),
+ }),
+ [state.allSites, state.resourceTypes, state.requestMethods, scope.domains, excluded.domains]
+ );
+
+ const submit = async (value: NetworkRuleFormValue) => {
+ const saved = await onSave(value);
+ if (saved === true) return;
+ setSubmitError(
+ saved !== false && saved.code === "revision_conflict"
+ ? t("tools:network_rules_revision_conflict")
+ : t("tools:network_rules_storage_error")
+ );
+ };
+
+ const handleSubmit = () => {
+ setTouched(true);
+ setSubmitError("");
+ if (!canSave) return;
+ const value: NetworkRuleFormValue = {
+ name: state.name.trim(),
+ condition,
+ action: buildAction(template, state.draft),
+ };
+ // 已经是「所有网站」的规则再次保存不必重复确认,只有创建与「编辑切入」才是新增的风险。
+ const wasAllSites = rule !== undefined && isAllSitesCondition(rule.condition);
+ if (state.allSites && !wasAllSites) {
+ setPendingValue(value);
+ return;
+ }
+ void submit(value);
+ };
+
+ return (
+ <>
+ !saving && onOpenChange(next)}>
+
+
+
+ {step === "form" && !rule && (
+
+ )}
+ {rule ? t("tools:network_rules_edit_title") : t("tools:network_rules_new_rule")}
+
+
+
+ {step === "template" ? (
+
+
+ {(["csp", "userAgent", "referer", "responseHeaders", "block", "redirect"] as const).map((id) => (
+
+ ))}
+
+
+
+ ) : (
+ setTouched(true)}
+ onChangeTemplate={() => setStep("template")}
+ onCancel={() => onOpenChange(false)}
+ onSubmit={handleSubmit}
+ />
+ )}
+
+
+
+ !next && setPendingValue(undefined)}>
+
+
+ {t("tools:network_rules_confirm_all_sites_title")}
+ {t("tools:network_rules_confirm_all_sites_description")}
+
+
+ setPendingValue(undefined)}>
+ {t("tools:network_rules_cancel")}
+
+ {
+ if (pendingValue) void submit(pendingValue);
+ setPendingValue(undefined);
+ }}
+ >
+ {t("tools:network_rules_confirm")}
+
+
+
+
+ >
+ );
+}
+
+function TemplateCard({
+ id,
+ labels,
+ className,
+ onSelect,
+}: {
+ id: RuleTemplateId;
+ labels: { title: string; description: string };
+ className?: string;
+ onSelect: (id: RuleTemplateId) => void;
+}) {
+ const Icon = TEMPLATE_ICONS[id];
+ // 「自定义」不绑定动作,动作要到表单里才选出来,所以它没有动作色。
+ const tone = id === "custom" ? "bg-muted text-muted-foreground" : ACTION_TONES[ACTION_TYPE_BY_TEMPLATE[id]];
+ return (
+
+ );
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx b/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx
new file mode 100644
index 000000000..6b37ee066
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/RuleTable.tsx
@@ -0,0 +1,225 @@
+import { memo, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { GripVertical } from "lucide-react";
+import type { DragEndEvent } from "@dnd-kit/core";
+import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { NetworkRule } from "@App/app/repo/network_rule";
+import { Checkbox } from "@App/pages/components/ui/checkbox";
+import { Switch } from "@App/pages/components/ui/switch";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@App/pages/components/ui/table";
+import { cn } from "@App/pkg/utils/cn";
+import { useDragAccessibility } from "./dragAccessibility";
+import {
+ ActionBadge,
+ RuleName,
+ RuleRowMenu,
+ ScopeChips,
+ useRuleRowLabels,
+ type RuleRowActions,
+ type RuleRowLabels,
+} from "./RuleParts";
+
+// 列宽 = 设计稿的内容宽 + 12px 列间距(单元格左右各 6px 内边距)。
+const COL = {
+ grip: "w-8",
+ select: "w-7",
+ enable: "w-14",
+ action: "w-[122px]",
+ scope: "w-[272px]",
+ order: "w-[68px]",
+ menu: "w-11",
+};
+
+export type RuleTableProps = RuleRowActions & {
+ rules: NetworkRule[];
+ positionOf: (rule: NetworkRule) => number;
+ total: number;
+ dragDisabled: boolean;
+ busy: boolean;
+ selected: Set;
+ onSelect: (id: string, checked: boolean) => void;
+ onSelectPage: (checked: boolean) => void;
+ onToggleEnabled: (rule: NetworkRule, enabled: boolean) => void;
+ onDragEnd: (activeId: string, overId: string) => void;
+};
+
+export default function RuleTable({
+ rules,
+ positionOf,
+ total,
+ dragDisabled,
+ busy,
+ selected,
+ onSelect,
+ onSelectPage,
+ onToggleEnabled,
+ onDragEnd,
+ ...moveHandlers
+}: RuleTableProps) {
+ const { t } = useTranslation();
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ );
+ const ids = useMemo(() => rules.map((rule) => rule.id), [rules]);
+ const labels = useRuleRowLabels();
+ const a11y = useDragAccessibility(rules, positionOf, total);
+ const allSelected = rules.length > 0 && rules.every((rule) => selected.has(rule.id));
+ const someSelected = rules.some((rule) => selected.has(rule.id));
+
+ const handleDragEnd = ({ active, over }: DragEndEvent) => {
+ if (over && active.id !== over.id) onDragEnd(`${active.id}`, `${over.id}`);
+ };
+
+ return (
+
+
+
+
+
+
+ onSelectPage(checked === true)}
+ />
+
+
+ {t("tools:network_rules_column_name")}
+ {t("tools:network_rules_column_action")}
+ {t("tools:network_rules_column_scope")}
+ {t("tools:network_rules_column_order")}
+
+
+
+
+
+ {rules.map((rule) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+type RuleRowProps = RuleRowActions & {
+ rule: NetworkRule;
+ position: number;
+ total: number;
+ dragDisabled: boolean;
+ busy: boolean;
+ labels: RuleRowLabels;
+ selected: boolean;
+ onSelect: (id: string, checked: boolean) => void;
+ onToggleEnabled: (rule: NetworkRule, enabled: boolean) => void;
+};
+
+/**
+ * 只有拖拽接线留在外层:useSortable 订阅 dnd-kit 的 context,父级一重渲染 context 就换标识,
+ * 整行连同 Radix 子树都会跟着重算,React.memo 包在外层也拦不住(已实测)。
+ * 把真正花钱的单元格放进 memo 边界内,外层就只剩一个按钮和几个稳定的属性。
+ */
+function SortableRuleRow({ rule, dragDisabled, labels, ...cellProps }: RuleRowProps) {
+ const { setNodeRef, setActivatorNodeRef, listeners, attributes, transform, transition, isDragging } = useSortable({
+ id: rule.id,
+ disabled: dragDisabled,
+ });
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+const RuleRowCells = memo(function RuleRowCells({
+ rule,
+ position,
+ total,
+ busy,
+ labels,
+ selected,
+ onSelect,
+ onToggleEnabled,
+ ...moveHandlers
+}: Omit) {
+ return (
+ <>
+
+ onSelect(rule.id, checked === true)}
+ />
+
+
+ onToggleEnabled(rule, checked)}
+ />
+
+
+
+
+
+
+
+
+
+
+ {position}
+
+
+
+ >
+ );
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx
new file mode 100644
index 000000000..159eddf1e
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/UxRefinements.test.tsx
@@ -0,0 +1,171 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { cspRemovalAction, type NetworkRule, type NetworkRuleState } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleMutationResult } from "@App/app/service/service_worker/network_rule";
+
+import NetworkRules from ".";
+import { stubNotify } from "./test-helpers";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+ Object.assign(chrome.tabs, { query: vi.fn().mockResolvedValue([]), reload: vi.fn().mockResolvedValue(undefined) });
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(index: number, over: Partial = {}): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ ...over,
+ };
+}
+
+function clientFor(rules: NetworkRule[], overrides: Partial = {}): NetworkRuleClient {
+ let state: NetworkRuleState = {
+ schemaVersion: 1,
+ revision: 3,
+ masterEnabled: true,
+ rules,
+ order: rules.map((r) => r.id),
+ };
+ const snapshot = () => ({ state, apply: { state: "applied" as const, revision: state.revision, appliedAt: 1 } });
+ const commit = (next: Partial): NetworkRuleMutationResult => {
+ state = { ...state, ...next, revision: state.revision + 1 };
+ return { ...snapshot(), outcome: "applied" as const };
+ };
+ return {
+ getState: vi.fn(async () => snapshot()),
+ createRule: vi.fn(),
+ updateRule: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ reorderRules: vi.fn(),
+ retryApply: vi.fn(),
+ setRulesEnabled: vi.fn(async ({ ids, enabled }: { ids: string[]; enabled: boolean }) =>
+ commit({ rules: state.rules.map((r) => (ids.includes(r.id) ? { ...r, enabled } : r)) })
+ ),
+ deleteRules: vi.fn(async ({ ids }: { ids: string[] }) =>
+ commit({
+ rules: state.rules.filter((r) => !ids.includes(r.id)),
+ order: state.order.filter((o) => !ids.includes(o)),
+ })
+ ),
+ ...overrides,
+ } as unknown as NetworkRuleClient;
+}
+
+function renderPage(client: NetworkRuleClient) {
+ return renderWithThemeRouter(
+
+ } />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+}
+
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+describe("网络规则空态", () => {
+ it("给出常用场景入口,点一下直接进到该场景的表单", async () => {
+ renderPage(clientFor([]));
+ expect(await screen.findByText("从一个常用场景开始,或自己新建一条")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "移除 CSP" }));
+ await flush();
+
+ const sheet = screen.getByRole("dialog");
+ // 直接落在第二步:场景徽标已经是「移除 CSP」,不需要用户再选一次。
+ expect(within(sheet).getByText("更换类型")).toBeInTheDocument();
+ expect(within(sheet).getByLabelText("应用范围")).toBeInTheDocument();
+ });
+});
+
+describe("网络规则筛选横幅", () => {
+ it("说明手柄为什么灰掉,而不只报匹配条数", async () => {
+ renderPage(clientFor([rule(1), rule(2)]));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 1" } });
+
+ const banner = screen.getByText(/筛选中/).closest("[role='status']");
+ expect(banner).toHaveTextContent("匹配 1 条");
+ expect(banner).toHaveTextContent("排序需要完整列表,已暂时停用拖拽");
+ });
+});
+
+describe("网络规则编辑抽屉的「试一试」", () => {
+ // 空态的场景入口直接落到第二步,正好省去先选模板这一步。
+ async function openTemplate(name: string) {
+ renderPage(clientFor([]));
+ fireEvent.click(await screen.findByRole("button", { name }));
+ await flush();
+ }
+
+ it("命中时说明会发生什么,而不只是「匹配」两个字", async () => {
+ await openTemplate("移除 CSP");
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } });
+ fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } });
+
+ expect(screen.getByText("匹配 · 将移除 4 个响应头")).toBeInTheDocument();
+ });
+
+ it("屏蔽请求场景说明请求会被屏蔽", async () => {
+ await openTemplate("屏蔽请求");
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } });
+ fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } });
+
+ expect(screen.getByText("匹配 · 将屏蔽该请求")).toBeInTheDocument();
+ });
+
+ it("范围命中但资源类型不含主文档时,说明这次导航不会被规则作用", async () => {
+ await openTemplate("屏蔽请求");
+ const sheet = screen.getByRole("dialog");
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } });
+ fireEvent.click(within(sheet).getByRole("button", { name: "高级选项" }));
+ await flush();
+ fireEvent.click(within(sheet).getByRole("checkbox", { name: "图片" }));
+ fireEvent.change(screen.getByLabelText("试一试"), { target: { value: "https://example.com/page" } });
+
+ expect(
+ screen.getByText("网址在范围内,但「高级选项」里限定的资源类型或请求方法不覆盖这次测试的主文档 GET 请求。")
+ ).toBeInTheDocument();
+ });
+});
+
+describe("网络规则的请求头黑名单", () => {
+ it("在输入之前就常驻说明哪些请求头不能改写", async () => {
+ renderPage(clientFor([]));
+ fireEvent.click(await screen.findByRole("button", { name: "自定义" }));
+ await flush();
+
+ // 「自定义」默认动作是屏蔽请求,没有请求头可填,说明也就不该出现。
+ expect(screen.queryByText(/不允许改写/)).not.toBeInTheDocument();
+
+ // Radix Select 在 happy-dom 下靠键盘打开,与 index.test.tsx 的 pickOption 一致。
+ fireEvent.keyDown(screen.getByRole("combobox", { name: "动作类型" }), { key: "Enter" });
+ await flush();
+ fireEvent.click(await screen.findByRole("option", { name: "改请求头" }));
+ await flush();
+
+ expect(screen.getByText("Cookie、Authorization、Host、Origin 不允许改写。")).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/dragAccessibility.ts b/src/pages/options/routes/Tools/NetworkRules/dragAccessibility.ts
new file mode 100644
index 000000000..36117e8d2
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/dragAccessibility.ts
@@ -0,0 +1,59 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import type { Announcements, UniqueIdentifier } from "@dnd-kit/core";
+import type { NetworkRule } from "@App/app/repo/network_rule";
+
+type DragAccessibility = {
+ container: HTMLElement;
+ announcements: Announcements;
+ screenReaderInstructions: { draggable: string };
+};
+
+/**
+ * dnd-kit 的内置播报是英文且只念规则 ID,读屏用户听不出拿起的是哪条规则、落到第几位。
+ * 位次取自完整列表(与「顺序」列一致),不是当前页内的序号。
+ */
+export function useDragAccessibility(
+ rules: NetworkRule[],
+ positionOf: (rule: NetworkRule) => number,
+ total: number
+): DragAccessibility {
+ const { t } = useTranslation();
+ return useMemo(() => {
+ const describe = (id: UniqueIdentifier | undefined) => {
+ const rule = rules.find((item) => item.id === id);
+ return rule && { name: rule.name, position: positionOf(rule) };
+ };
+ return {
+ container: document.body,
+ screenReaderInstructions: { draggable: t("tools:network_rules_drag_instructions") },
+ announcements: {
+ onDragStart: ({ active }) => {
+ const dragged = describe(active.id);
+ return dragged && t("tools:network_rules_drag_picked_up", { ...dragged, total });
+ },
+ onDragOver: ({ active, over }) => {
+ const dragged = describe(active.id);
+ const target = describe(over?.id);
+ return (
+ dragged &&
+ target &&
+ t("tools:network_rules_drag_moved_over", { name: dragged.name, position: target.position })
+ );
+ },
+ onDragEnd: ({ active, over }) => {
+ const dragged = describe(active.id);
+ if (!dragged) return undefined;
+ const target = describe(over?.id);
+ return target
+ ? t("tools:network_rules_drag_dropped", { name: dragged.name, position: target.position })
+ : t("tools:network_rules_drag_cancelled", dragged);
+ },
+ onDragCancel: ({ active }) => {
+ const dragged = describe(active.id);
+ return dragged && t("tools:network_rules_drag_cancelled", dragged);
+ },
+ },
+ };
+ }, [t, rules, positionOf, total]);
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/index.test.tsx b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx
new file mode 100644
index 000000000..da9f1a8a8
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/index.test.tsx
@@ -0,0 +1,478 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { notify } from "@App/pages/components/ui/toast";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import type { NetworkRuleClient } from "@App/app/service/service_worker/client";
+import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+
+import NetworkRules from ".";
+import { NETWORK_RULES_PAGE_SIZE } from "./rules";
+import { stubNotify } from "./test-helpers";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+ stubNotify();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+});
+
+function rule(index: number, over: Partial = {}): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: 1,
+ ...over,
+ };
+}
+
+function snapshot(rules: NetworkRule[], order = rules.map((r) => r.id)): NetworkRuleSnapshot {
+ return {
+ state: { schemaVersion: 1, revision: 3, masterEnabled: true, rules, order },
+ apply: { state: "applied", revision: 3, appliedAt: 1 },
+ };
+}
+
+function clientFor(current: NetworkRuleSnapshot, overrides: Partial = {}): NetworkRuleClient {
+ const mutated = {
+ state: { ...current.state, revision: current.state.revision + 1 },
+ apply: { state: "applied" as const, revision: current.state.revision + 1, appliedAt: 2 },
+ outcome: "applied" as const,
+ };
+ return {
+ getState: vi.fn().mockResolvedValue(current),
+ createRule: vi.fn().mockResolvedValue(mutated),
+ updateRule: vi.fn().mockResolvedValue(mutated),
+ deleteRules: vi.fn().mockResolvedValue(mutated),
+ setRulesEnabled: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ // 服务端会带着新顺序回包,mock 必须还原这一点,否则页面无从判断保存是否生效。
+ reorderRules: vi.fn(async ({ order }: { order: string[] }) => ({
+ state: { ...current.state, revision: current.state.revision + 1, order },
+ apply: { state: "applied" as const, revision: current.state.revision + 1, appliedAt: 2 },
+ outcome: "applied" as const,
+ })),
+ retryApply: vi.fn(),
+ ...overrides,
+ } as unknown as NetworkRuleClient;
+}
+
+function renderPage(client: NetworkRuleClient) {
+ return renderWithThemeRouter(
+
+ } />
+ ,
+ { initialEntries: ["/tools/network-rules"] }
+ );
+}
+
+function rowNames(): string[] {
+ return screen
+ .getAllByTestId("network-rule-row")
+ .map((row) => within(row).getByTestId("network-rule-name").textContent ?? "");
+}
+
+/** dnd-kit 依赖真实排版测量,happy-dom 无布局,用行序号伪造矩形驱动键盘传感器。 */
+function stubRowRects() {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
+ const row = this.closest("[data-testid='network-rule-row']") as HTMLElement | null;
+ const index = row ? screen.getAllByTestId("network-rule-row").indexOf(row) : -1;
+ const top = index < 0 ? 0 : index * 60;
+ return {
+ x: 0,
+ y: top,
+ top,
+ left: 0,
+ right: 900,
+ bottom: top + 60,
+ width: 900,
+ height: 60,
+ toJSON: () => ({}),
+ } as DOMRect;
+ });
+}
+
+/** KeyboardSensor 的 keydown 监听器在 setTimeout 里挂载,激活后必须让出一次事件循环。 */
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+async function openRowMenu(row: HTMLElement) {
+ const trigger = within(row).getByRole("button", { name: "更多操作" });
+ fireEvent.pointerDown(trigger, { button: 0 });
+ fireEvent.click(trigger);
+ await flush();
+}
+
+describe("网络规则列表页", () => {
+ it("按列表顺序渲染全部规则,而不是按存储数组顺序", async () => {
+ const rules = [rule(1), rule(2), rule(3)];
+ const client = clientFor(snapshot(rules, ["r3", "r1", "r2"]));
+ renderPage(client);
+
+ expect(await screen.findByText("规则 3")).toBeInTheDocument();
+ expect(rowNames()).toEqual(["规则 3", "规则 1", "规则 2"]);
+ });
+
+ it("把第 3 行拖到首位后立即持久化新顺序", async () => {
+ const client = clientFor(snapshot([rule(1), rule(2), rule(3)]));
+ renderPage(client);
+ expect(await screen.findByText("规则 3")).toBeInTheDocument();
+ stubRowRects();
+
+ const handle = within(screen.getAllByTestId("network-rule-row")[2]).getByRole("button", { name: /规则 3/ });
+ fireEvent.keyDown(handle, { code: "Space" });
+ await flush();
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "Space" });
+ await flush();
+
+ expect(client.reorderRules).toHaveBeenCalledWith({ baseRevision: 3, order: ["r3", "r1", "r2"] });
+ expect(rowNames()).toEqual(["规则 3", "规则 1", "规则 2"]);
+ });
+
+ it("排序保存失败时回滚显示顺序并提示", async () => {
+ const client = clientFor(snapshot([rule(1), rule(2), rule(3)]), {
+ reorderRules: vi.fn().mockRejectedValue(JSON.stringify({ code: "storage_write_failed" })),
+ });
+ renderPage(client);
+ expect(await screen.findByText("规则 3")).toBeInTheDocument();
+ stubRowRects();
+
+ const handle = within(screen.getAllByTestId("network-rule-row")[2]).getByRole("button", { name: /规则 3/ });
+ fireEvent.keyDown(handle, { code: "Space" });
+ await flush();
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "ArrowUp" });
+ fireEvent.keyDown(handle, { code: "Space" });
+ await flush();
+
+ expect(client.reorderRules).toHaveBeenCalled();
+ expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]);
+ expect(notify.error).toHaveBeenCalledWith("顺序未能保存,已恢复原顺序。");
+ });
+
+ it("搜索时手柄置灰,但行菜单的置顶仍能跨页移动规则", async () => {
+ // 只有第二页存在时「跨页」才成立,刚好多出一条即可;多余的行只会让整页渲染更贵。
+ const total = NETWORK_RULES_PAGE_SIZE + 1;
+ const offPage = total - 1;
+ const rules = Array.from({ length: total }, (_, index) => rule(index));
+ const client = clientFor(snapshot(rules));
+ renderPage(client);
+ expect(await screen.findByText("规则 0")).toBeInTheDocument();
+ expect(screen.getAllByTestId("network-rule-row")).toHaveLength(NETWORK_RULES_PAGE_SIZE);
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: `规则 ${offPage}` } });
+ const row = screen.getAllByTestId("network-rule-row")[0];
+ expect(rowNames()).toEqual([`规则 ${offPage}`]);
+ expect(within(row).getByRole("button", { name: new RegExp(`规则 ${offPage}`) })).toBeDisabled();
+
+ await openRowMenu(row);
+ fireEvent.click(await screen.findByRole("menuitem", { name: "置顶" }));
+ await flush();
+
+ const order = vi.mocked(client.reorderRules).mock.calls[0][0].order;
+ expect(order).toHaveLength(total);
+ expect(order[0]).toBe(`r${offPage}`);
+ });
+
+ it("清除筛选后恢复完整列表,手柄重新可用", async () => {
+ // 筛掉一部分再清掉筛选就足以验证恢复,不需要凑满一整页。
+ renderPage(clientFor(snapshot([rule(1), rule(2), rule(3)])));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "规则 2" } });
+ expect(rowNames()).toEqual(["规则 2"]);
+
+ fireEvent.click(screen.getByRole("button", { name: "清除筛选" }));
+ expect(rowNames()).toEqual(["规则 1", "规则 2", "规则 3"]);
+ expect(
+ within(screen.getAllByTestId("network-rule-row")[0]).getByRole("button", { name: /规则 1/ })
+ ).not.toBeDisabled();
+ });
+
+ it("行菜单可以把规则移到指定位置", async () => {
+ // 目标位次落在中间,才和「置顶/置底」区分开;四行就够摆出这样一个位次。
+ const client = clientFor(snapshot([rule(1), rule(2), rule(3), rule(4)]));
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ await openRowMenu(screen.getAllByTestId("network-rule-row")[0]);
+ fireEvent.click(await screen.findByRole("menuitem", { name: "移到…" }));
+ fireEvent.change(await screen.findByRole("spinbutton"), { target: { value: "3" } });
+ fireEvent.click(screen.getByRole("button", { name: "移动" }));
+ await flush();
+
+ const order = vi.mocked(client.reorderRules).mock.calls[0][0].order;
+ expect(order).toEqual(["r2", "r3", "r1", "r4"]);
+ });
+
+ it("没有规则时展示空态", async () => {
+ renderPage(clientFor(snapshot([])));
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+ expect(screen.queryAllByTestId("network-rule-row")).toHaveLength(0);
+ });
+
+ it("搜索无结果时展示无结果态而不是空态", async () => {
+ renderPage(clientFor(snapshot([rule(1)])));
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "不存在" } });
+ expect(screen.getByText("没有匹配的规则")).toBeInTheDocument();
+ expect(screen.queryByText("还没有网络规则")).not.toBeInTheDocument();
+ });
+
+ it("应用失败时在表格上方显示横幅并可重试", async () => {
+ const current = snapshot([rule(1)]);
+ current.apply = {
+ state: "error",
+ code: "dnr_apply_failed",
+ desiredRevision: 3,
+ lastAppliedRevision: 2,
+ message: "Rule limit exceeded",
+ };
+ const client = clientFor(current, {
+ retryApply: vi.fn().mockResolvedValue({ ...snapshot([rule(1)]), outcome: "applied" as const }),
+ });
+ renderPage(client);
+
+ expect(await screen.findByText("规则未能应用到浏览器")).toBeInTheDocument();
+ expect(screen.getByText(/Rule limit exceeded/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "重试" }));
+ await flush();
+ expect(client.retryApply).toHaveBeenCalled();
+ });
+});
+
+const TEMPLATE_NAMES = [
+ "移除 CSP",
+ "修改 User-Agent",
+ "修改 Referer",
+ "修改响应头",
+ "屏蔽请求",
+ "重定向请求",
+ "自定义",
+];
+
+async function openCreateSheet() {
+ fireEvent.click(screen.getByRole("button", { name: "新建规则" }));
+ await flush();
+}
+
+async function pickTemplate(name: string) {
+ fireEvent.click(await screen.findByRole("button", { name: new RegExp(name) }));
+ await flush();
+}
+
+async function pickOption(comboboxName: string, optionText: string) {
+ fireEvent.keyDown(screen.getByRole("combobox", { name: comboboxName }), { key: "Enter" });
+ await flush();
+ fireEvent.click(await screen.findByRole("option", { name: optionText }));
+ await flush();
+}
+
+async function openRowAction(row: HTMLElement, item: string) {
+ await openRowMenu(row);
+ fireEvent.click(await screen.findByRole("menuitem", { name: item }));
+ await flush();
+}
+
+describe("网络规则编辑抽屉", () => {
+ it("新建规则先展示七个场景模板", async () => {
+ renderPage(clientFor(snapshot([])));
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+
+ await openCreateSheet();
+
+ for (const name of TEMPLATE_NAMES) {
+ expect(screen.getByRole("button", { name: new RegExp(name) })).toBeInTheDocument();
+ }
+ });
+
+ it("改写 Cookie 请求头在输入阶段就被拦下,保存不会发出任何请求", async () => {
+ const client = clientFor(snapshot([]));
+ renderPage(client);
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+
+ await openCreateSheet();
+ await pickTemplate("自定义");
+ await pickOption("动作类型", "改请求头");
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } });
+ fireEvent.change(screen.getAllByLabelText("头名称")[0], { target: { value: "Cookie" } });
+
+ // 黑名单说明常驻在这一栏,所以这里要认的是报错本身,而不是页面上出现了这几个字。
+ expect(screen.getByRole("alert")).toHaveTextContent("会暴露或伪造调用者身份");
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await flush();
+ expect(client.createRule).not.toHaveBeenCalled();
+ });
+
+ it("移除 CSP 模板预填四个 CSP 响应头,X-Frame-Options 可选附带", async () => {
+ const client = clientFor(snapshot([]));
+ renderPage(client);
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+
+ await openCreateSheet();
+ await pickTemplate("移除 CSP");
+ for (const header of [
+ "content-security-policy",
+ "content-security-policy-report-only",
+ "x-content-security-policy",
+ "x-webkit-csp",
+ ]) {
+ expect(screen.getByText(header)).toBeInTheDocument();
+ }
+ expect(screen.queryByText("x-frame-options")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "同时移除 X-Frame-Options" }));
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "github.com" } });
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await flush();
+
+ expect(client.createRule).toHaveBeenCalledWith(
+ expect.objectContaining({
+ condition: expect.objectContaining({ requestDomains: ["github.com"] }),
+ action: {
+ type: "removeResponseHeaders",
+ headers: [
+ "content-security-policy",
+ "content-security-policy-report-only",
+ "x-content-security-policy",
+ "x-webkit-csp",
+ "x-frame-options",
+ ],
+ },
+ })
+ );
+ });
+
+ it("编辑既有规则直接进入第二步,更换类型退回第一步并保留应用范围", async () => {
+ const client = clientFor(snapshot([rule(1)]));
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ await openRowAction(screen.getAllByTestId("network-rule-row")[0], "编辑");
+ expect(screen.getByLabelText("应用范围")).toHaveValue("s1.example.com");
+ expect(screen.queryByRole("button", { name: /屏蔽请求/ })).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "更换类型" }));
+ await flush();
+ for (const name of TEMPLATE_NAMES) {
+ expect(screen.getByRole("button", { name: new RegExp(name) })).toBeInTheDocument();
+ }
+
+ await pickTemplate("屏蔽请求");
+ expect(screen.getByLabelText("应用范围")).toHaveValue("s1.example.com");
+
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await flush();
+ expect(client.updateRule).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "r1", patch: expect.objectContaining({ action: { type: "block" } }) })
+ );
+ });
+
+ it("排除域名超过单条规则上限时挡下保存并说明原因", async () => {
+ const client = clientFor(snapshot([]));
+ renderPage(client);
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+
+ await openCreateSheet();
+ await pickTemplate("屏蔽请求");
+ fireEvent.change(screen.getByLabelText("应用范围"), { target: { value: "example.com" } });
+ fireEvent.click(screen.getByRole("button", { name: "高级选项" }));
+ fireEvent.change(await screen.findByLabelText("排除域名"), {
+ target: { value: Array.from({ length: 101 }, (_, index) => `d${index}.example.com`).join("\n") },
+ });
+
+ expect(screen.getByText("每条规则请输入 1 至 100 个域名。")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await flush();
+ expect(client.createRule).not.toHaveBeenCalled();
+ });
+
+ it("保存「所有网站」规则前必须二次确认", async () => {
+ const client = clientFor(snapshot([]));
+ renderPage(client);
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+
+ await openCreateSheet();
+ await pickTemplate("屏蔽请求");
+ fireEvent.click(screen.getByRole("checkbox", { name: /所有网站/ }));
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await flush();
+
+ expect(client.createRule).not.toHaveBeenCalled();
+ expect(screen.getByText("影响所有网站?")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "继续" }));
+ await flush();
+ expect(client.createRule).toHaveBeenCalledWith(
+ expect.objectContaining({ condition: expect.objectContaining({ urlFilter: "*" }) })
+ );
+ });
+
+ it("删除规则需确认,并提示可以改用停用", async () => {
+ const client = clientFor(snapshot([rule(1)]));
+ renderPage(client);
+ expect(await screen.findByText("规则 1")).toBeInTheDocument();
+
+ await openRowAction(screen.getAllByTestId("network-rule-row")[0], "删除");
+ expect(client.deleteRules).not.toHaveBeenCalled();
+ expect(screen.getByText(/停用/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "删除规则" }));
+ await flush();
+ expect(client.deleteRules).toHaveBeenCalledWith({ baseRevision: 3, ids: ["r1"] });
+ });
+});
+
+describe("网络规则配额提示", () => {
+ const mixedRules = [
+ rule(1, { action: { type: "block" } }),
+ rule(2, { action: { type: "redirect", url: "https://example.com/x.js" } }),
+ rule(3, { action: { type: "allow" } }),
+ rule(4, { action: cspRemovalAction() }),
+ rule(5, { action: { type: "block" }, enabled: false }),
+ ];
+
+ it("按动作类型分别提示占用:block 走总池,改头/重定向/放行另占 unsafe 池", async () => {
+ renderPage(clientFor(snapshot(mixedRules)));
+ const quota = await screen.findByTestId("network-rules-quota");
+
+ // 停用的规则不会被编译成 DNR 规则,因此不占配额:总池 4 条,其中 3 条是 unsafe。
+ expect(quota).toHaveTextContent(`4/${chrome.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_RULES}`);
+ expect(quota).toHaveTextContent(`3/${chrome.declarativeNetRequest.MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES}`);
+ });
+
+ it("浏览器不区分 unsafe 池时只提示总池,且数字仍取自运行时常量", async () => {
+ // Firefox 不实现 safe/unsafe 分池,两个 Chrome 专属常量在那里根本不存在。
+ const absent = ["MAX_NUMBER_OF_DYNAMIC_RULES", "MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES"] as const;
+ const saved = absent.map((name) => chrome.declarativeNetRequest[name]);
+ for (const name of absent) {
+ Object.defineProperty(chrome.declarativeNetRequest, name, { value: undefined, configurable: true });
+ }
+ try {
+ renderPage(clientFor(snapshot(mixedRules)));
+ const quota = await screen.findByTestId("network-rules-quota");
+ expect(quota).toHaveTextContent(`4/${chrome.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES}`);
+ expect(quota.textContent).not.toContain("3/");
+ } finally {
+ absent.forEach((name, index) =>
+ Object.defineProperty(chrome.declarativeNetRequest, name, { value: saved[index], configurable: true })
+ );
+ }
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/index.tsx b/src/pages/options/routes/Tools/NetworkRules/index.tsx
new file mode 100644
index 000000000..f73d90433
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/index.tsx
@@ -0,0 +1,797 @@
+import { useCallback, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle, ArrowLeft, CircleCheck, CircleSlash, Loader2, Minus, Network, Trash2 } from "lucide-react";
+import { arrayMove } from "@dnd-kit/sortable";
+import type { NetworkRule, NetworkRuleCondition } from "@App/app/repo/network_rule";
+import {
+ NetworkRuleAmbiguousResponseError,
+ parseNetworkRuleError,
+ type NetworkRuleClient,
+} from "@App/app/service/service_worker/client";
+import type { NetworkRuleMutationResult } from "@App/app/service/service_worker/network_rule";
+import { networkRuleClient } from "@App/pages/store/features/script";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@App/pages/components/ui/alert-dialog";
+import { Badge } from "@App/pages/components/ui/badge";
+import { Button } from "@App/pages/components/ui/button";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@App/pages/components/ui/dialog";
+import { EmptyState } from "@App/pages/components/ui/empty-state";
+import { Input } from "@App/pages/components/ui/input";
+import { Label } from "@App/pages/components/ui/label";
+import { Pagination } from "@App/pages/components/ui/pagination";
+import { SearchInput } from "@App/pages/components/ui/search-input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select";
+import { Skeleton } from "@App/pages/components/ui/skeleton";
+import { Switch } from "@App/pages/components/ui/switch";
+import { notify } from "@App/pages/components/ui/toast";
+import { useIsMobile } from "@App/pages/components/use-is-mobile";
+import MatchTestDialog from "./MatchTestDialog";
+import { useActionLabels } from "./RuleParts";
+import RuleCards from "./RuleCards";
+import RuleSheet, { useTemplateLabels, type NetworkRuleFormValue, type NetworkRuleSaveResult } from "./RuleSheet";
+import RuleTable from "./RuleTable";
+import { reloadRuleTabs } from "./tabs";
+import { useNetworkRuleSnapshot } from "./useNetworkRuleSnapshot";
+import type { RuleTemplateId } from "./templates";
+import {
+ ACTION_FILTER_OPTIONS,
+ enabledCount,
+ filterRules,
+ filtersActive,
+ isAllSitesCondition,
+ moveRuleTo,
+ NETWORK_RULES_PAGE_SIZE,
+ orderedRules,
+ quotaLimits,
+ quotaUsage,
+ type ActionFilter,
+ type StatusFilter,
+} from "./rules";
+
+/**
+ * 空态的入口:四个常用场景分属四种动作(删响应头、改请求头、屏蔽、重定向),
+ * 末尾的「自定义」对应文案里的「或自己新建一条」。工具栏的新建按钮不在这里重复。
+ */
+const EMPTY_STATE_TEMPLATES: RuleTemplateId[] = ["csp", "userAgent", "block", "redirect", "custom"];
+
+export default function NetworkRules({ client: injectedClient }: { client?: NetworkRuleClient }) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const isMobile = useIsMobile();
+ const client = useMemo(() => injectedClient ?? networkRuleClient, [injectedClient]);
+ const actionLabels = useActionLabels();
+ const templateLabels = useTemplateLabels();
+
+ const { snapshot, setSnapshot, loading, loadError, setLoadError } = useNetworkRuleSnapshot(client);
+ const [busy, setBusy] = useState();
+ const [query, setQuery] = useState("");
+ const [actionFilter, setActionFilter] = useState("all");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [page, setPage] = useState(1);
+ const [selected, setSelected] = useState>(new Set());
+ // 拖拽/移动期间先按新顺序渲染,保存失败时清空即回滚到服务端顺序。
+ const [pendingOrder, setPendingOrder] = useState();
+ const [movingRule, setMovingRule] = useState();
+ // 高危确认与删除确认都可能由单条或一批规则触发,所以存待执行的动作而不是某一条规则。
+ const [confirmAllSites, setConfirmAllSites] = useState<{ run: () => void }>();
+ const [confirmDelete, setConfirmDelete] = useState();
+ const [editingRule, setEditingRule] = useState();
+ const [sheetTemplate, setSheetTemplate] = useState();
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const [matchTestOpen, setMatchTestOpen] = useState(false);
+
+ const state = snapshot?.state;
+ const paused = state !== undefined && !state.masterEnabled;
+ const applyErrored = snapshot?.apply.state === "error";
+ const order = useMemo(() => pendingOrder ?? state?.order ?? [], [pendingOrder, state?.order]);
+ const rules = useMemo(() => orderedRules(state?.rules ?? [], order), [state?.rules, order]);
+ const filters = useMemo(
+ () => ({ query, action: actionFilter, status: statusFilter }),
+ [query, actionFilter, statusFilter]
+ );
+ const isFiltered = filtersActive(filters);
+ const visible = useMemo(() => filterRules(rules, filters), [rules, filters]);
+ const pageCount = Math.max(1, Math.ceil(visible.length / NETWORK_RULES_PAGE_SIZE));
+ const currentPage = Math.min(page, pageCount);
+ const pageRules = useMemo(
+ () => visible.slice((currentPage - 1) * NETWORK_RULES_PAGE_SIZE, currentPage * NETWORK_RULES_PAGE_SIZE),
+ [visible, currentPage]
+ );
+ const positionOf = useCallback((rule: NetworkRule) => order.indexOf(rule.id) + 1, [order]);
+ const quota = useMemo(() => quotaUsage(rules), [rules]);
+ const limits = useMemo(() => quotaLimits(), []);
+ // 勾选以当前页为范围,所以翻页与改筛选都会清空选择,批量操作永远只作用于看得见的行。
+ const selectedRules = useMemo(() => pageRules.filter((rule) => selected.has(rule.id)), [pageRules, selected]);
+ const clearSelection = useCallback(() => setSelected(new Set()), []);
+
+ const clearFilters = () => {
+ setQuery("");
+ setActionFilter("all");
+ setStatusFilter("all");
+ setPage(1);
+ clearSelection();
+ };
+
+ /** 返回是否真的落到了浏览器:写入成功但 DNR 没接受时只报失败,调用方不得再报成功。 */
+ const applyResult = useCallback(
+ (result: NetworkRuleMutationResult): boolean => {
+ setSnapshot(result);
+ if (result.outcome !== "applied" || result.apply.state !== "applied") {
+ notify.error(t("tools:network_rules_rule_saved_apply_failed"));
+ return false;
+ }
+ return true;
+ },
+ [setSnapshot, t]
+ );
+
+ // 刷新范围由改动波及的规则条件决定,见 tabs.ts。
+ const notifyApplied = useCallback(
+ (title: string, conditions: NetworkRuleCondition[]) =>
+ notify.success(title, {
+ description: t("tools:network_rules_reload_hint"),
+ action: { label: t("tools:network_rules_reload_tabs"), onClick: () => void reloadRuleTabs(conditions) },
+ }),
+ [t]
+ );
+
+ // service worker 挂起时响应可能丢失,但请求已在后台生效:重新拉取权威 state 判断是否推进了 revision。
+ const settleAmbiguous = useCallback(
+ async (baseRevision: number): Promise => {
+ try {
+ const latest = await client.getState();
+ setSnapshot(latest);
+ return latest.state.revision > baseRevision ? latest.state.revision : undefined;
+ } catch {
+ return undefined;
+ }
+ },
+ [client, setSnapshot]
+ );
+
+ const mutate = useCallback(
+ async (
+ key: string,
+ run: (baseRevision: number) => Promise,
+ failureText: string
+ ): Promise => {
+ if (!state) return false;
+ const baseRevision = state.revision;
+ setBusy(key);
+ try {
+ return applyResult(await run(baseRevision));
+ } catch (error) {
+ if (error instanceof NetworkRuleAmbiguousResponseError) {
+ if ((await settleAmbiguous(baseRevision)) !== undefined) return true;
+ notify.error(failureText);
+ return false;
+ }
+ const parsed = parseNetworkRuleError(error);
+ if (parsed.snapshot) setSnapshot(parsed.snapshot);
+ notify.error(parsed.code === "revision_conflict" ? t("tools:network_rules_revision_conflict") : failureText);
+ return false;
+ } finally {
+ setBusy(undefined);
+ }
+ },
+ [state, applyResult, settleAmbiguous, setSnapshot, t]
+ );
+
+ const persistOrder = useCallback(
+ async (nextOrder: string[]) => {
+ setPendingOrder(nextOrder);
+ const ok = await mutate(
+ "reorder",
+ (baseRevision) => client.reorderRules({ baseRevision, order: nextOrder }),
+ t("tools:network_rules_reorder_failed")
+ );
+ setPendingOrder(undefined);
+ // 顺序即优先级,改一处会牵动全部规则,刷新范围取所有规则的条件。
+ if (ok)
+ notifyApplied(
+ t("tools:network_rules_rule_saved"),
+ rules.map((rule) => rule.condition)
+ );
+ },
+ [client, mutate, notifyApplied, rules, t]
+ );
+
+ const handleDragEnd = useCallback(
+ (activeId: string, overId: string) => {
+ const from = order.indexOf(activeId);
+ const to = order.indexOf(overId);
+ if (from < 0 || to < 0) return;
+ void persistOrder(arrayMove(order, from, to));
+ },
+ [order, persistOrder]
+ );
+
+ const moveTo = useCallback(
+ (rule: NetworkRule, index: number) => void persistOrder(moveRuleTo(order, rule.id, index)),
+ [order, persistOrder]
+ );
+
+ const setRuleEnabled = useCallback(
+ (rule: NetworkRule, enabled: boolean) =>
+ mutate(
+ rule.id,
+ (baseRevision) => client.setRulesEnabled({ baseRevision, ids: [rule.id], enabled }),
+ t("tools:network_rules_storage_error")
+ ),
+ [client, mutate, t]
+ );
+
+ const toggleEnabled = useCallback(
+ (rule: NetworkRule, enabled: boolean) => {
+ if (enabled && isAllSitesCondition(rule.condition)) {
+ setConfirmAllSites({ run: () => void setRuleEnabled(rule, enabled) });
+ return;
+ }
+ void setRuleEnabled(rule, enabled);
+ },
+ [setRuleEnabled]
+ );
+
+ const setSelectedEnabled = (enabled: boolean) => {
+ const targets = selectedRules.filter((rule) => rule.enabled !== enabled);
+ const run = async () => {
+ if (targets.length === 0) {
+ clearSelection();
+ return;
+ }
+ const ok = await mutate(
+ "bulk",
+ (baseRevision) => client.setRulesEnabled({ baseRevision, ids: targets.map((rule) => rule.id), enabled }),
+ t("tools:network_rules_storage_error")
+ );
+ // 整批要么全落地要么全被拒,失败时保留选中项,用户可以直接重试。
+ if (ok) clearSelection();
+ };
+ if (enabled && targets.some((rule) => isAllSitesCondition(rule.condition))) {
+ setConfirmAllSites({ run: () => void run() });
+ return;
+ }
+ void run();
+ };
+
+ const setMasterEnabled = (enabled: boolean) =>
+ void mutate(
+ "master",
+ (baseRevision) => client.setMasterEnabled({ baseRevision, enabled }),
+ t("tools:network_rules_storage_error")
+ );
+
+ const retry = async () => {
+ setBusy("retry");
+ try {
+ applyResult(await client.retryApply());
+ setLoadError(undefined);
+ } catch (error) {
+ setLoadError(parseNetworkRuleError(error));
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const openSheet = useCallback((rule?: NetworkRule, template?: RuleTemplateId) => {
+ setEditingRule(rule);
+ setSheetTemplate(template);
+ setSheetOpen(true);
+ }, []);
+
+ // 抽屉自己展示保存失败的原因,所以这里不走 mutate 的 toast,只把服务端错误原样回传。
+ const saveRule = async (value: NetworkRuleFormValue): Promise => {
+ if (!state) return false;
+ const baseRevision = state.revision;
+ setBusy("sheet");
+ try {
+ const result = editingRule
+ ? await client.updateRule({
+ baseRevision,
+ id: editingRule.id,
+ patch: { name: value.name, condition: value.condition, action: value.action },
+ })
+ : await client.createRule({
+ baseRevision,
+ enabled: true,
+ name: value.name,
+ condition: value.condition,
+ action: value.action,
+ });
+ if (applyResult(result)) notifyApplied(t("tools:network_rules_rule_saved"), [value.condition]);
+ setSheetOpen(false);
+ return true;
+ } catch (error) {
+ if (error instanceof NetworkRuleAmbiguousResponseError) {
+ if ((await settleAmbiguous(baseRevision)) !== undefined) {
+ setSheetOpen(false);
+ return true;
+ }
+ return false;
+ }
+ const parsed = parseNetworkRuleError(error);
+ if (parsed.snapshot) setSnapshot(parsed.snapshot);
+ return parsed;
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const deleteRules = async (rules: NetworkRule[]) => {
+ const ok = await mutate(
+ "bulk",
+ (baseRevision) => client.deleteRules({ baseRevision, ids: rules.map((rule) => rule.id) }),
+ t("tools:network_rules_storage_error")
+ );
+ if (!ok) return;
+ clearSelection();
+ notifyApplied(
+ t("tools:network_rules_rule_deleted"),
+ rules.map((rule) => rule.condition)
+ );
+ };
+
+ const toggleSelect = useCallback(
+ (id: string, checked: boolean) =>
+ setSelected((current) => {
+ const next = new Set(current);
+ if (checked) next.add(id);
+ else next.delete(id);
+ return next;
+ }),
+ []
+ );
+ const selectPage = useCallback(
+ (checked: boolean) =>
+ setSelected((current) => {
+ const next = new Set(current);
+ for (const rule of pageRules) {
+ if (checked) next.add(rule.id);
+ else next.delete(rule.id);
+ }
+ return next;
+ }),
+ [pageRules]
+ );
+
+ // 每行都是 memo 化的,行处理函数一旦跟着 pageRules 重建,翻页与搜索就会把整页行重算一遍。
+ const editRule = useCallback((rule: NetworkRule) => openSheet(rule), [openSheet]);
+ const deleteRule = useCallback((rule: NetworkRule) => setConfirmDelete([rule]), []);
+ const moveTop = useCallback((rule: NetworkRule) => moveTo(rule, 0), [moveTo]);
+ const moveBottom = useCallback((rule: NetworkRule) => moveTo(rule, order.length - 1), [moveTo, order.length]);
+ const moveToPosition = useCallback((rule: NetworkRule) => setMovingRule(rule), []);
+
+ const listProps = useMemo(
+ () => ({
+ rules: pageRules,
+ positionOf,
+ total: order.length,
+ dragDisabled: isFiltered || busy !== undefined,
+ busy: busy !== undefined,
+ onToggleEnabled: toggleEnabled,
+ onDragEnd: handleDragEnd,
+ onEdit: editRule,
+ onDelete: deleteRule,
+ onMoveTop: moveTop,
+ onMoveBottom: moveBottom,
+ onMoveTo: moveToPosition,
+ }),
+ [
+ pageRules,
+ positionOf,
+ order.length,
+ isFiltered,
+ busy,
+ toggleEnabled,
+ handleDragEnd,
+ editRule,
+ deleteRule,
+ moveTop,
+ moveBottom,
+ moveToPosition,
+ ]
+ );
+
+ return (
+
+
+
+
+
+ {
+ setQuery(event.target.value);
+ setPage(1);
+ clearSelection();
+ }}
+ />
+
+
+
+
+
+
+ {isFiltered && rules.length > 0 && (
+
+ {t("tools:network_rules_filter_active", { matched: visible.length, total: rules.length })}
+ {/* 手柄灰掉是筛选的后果而不是又一次复述按钮,不说明用户无从得知为什么不能拖。 */}
+ {t("tools:network_rules_filter_drag_paused")}
+
+
+ )}
+
+ {snapshot?.apply.state === "error" && (
+
+
+
+
{t("tools:network_rules_apply_error_title")}
+
+ {t("tools:network_rules_apply_error_detail")} {snapshot.apply.message}
+
+
+
+
+ )}
+
+ {loading && (
+
+
+
+
+
+ )}
+
+ {!loading && loadError && (
+
+
+ {t(
+ loadError.code === "unsupported_schema"
+ ? "tools:network_rules_unsupported_schema"
+ : "tools:network_rules_load_error"
+ )}
+
+
+
+ )}
+
+ {!loading && !loadError && rules.length === 0 && (
+
+ {EMPTY_STATE_TEMPLATES.map((id) => (
+
+ ))}
+
+ }
+ />
+ )}
+
+ {!loading && !loadError && rules.length > 0 && visible.length === 0 && (
+
+ {t("tools:network_rules_clear_filters")}
+
+ }
+ />
+ )}
+
+ {selectedRules.length > 0 && (
+
+
+
+
+
+ {t("tools:network_rules_selected_count", { count: selectedRules.length })}
+
+
+
+
+
+
+ )}
+ {!loading && !loadError && pageRules.length > 0 && (
+ <>
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+
+
+ {t("tools:network_rules_footer_total", {
+ count: rules.length,
+ enabled: state ? enabledCount(state) : 0,
+ })}
+
+ {limits.total !== undefined && (
+
+ {t("tools:network_rules_quota_total", { used: quota.total, limit: limits.total })}
+ {limits.unsafe !== undefined &&
+ ` · ${t("tools:network_rules_quota_unsafe", { used: quota.unsafe, limit: limits.unsafe })}`}
+
+ )}
+
+ {pageCount > 1 && (
+
{
+ setPage(next);
+ clearSelection();
+ }}
+ previousLabel={t("tools:network_rules_prev_page")}
+ nextLabel={t("tools:network_rules_next_page")}
+ />
+ )}
+
+ >
+ )}
+
+
+ setMovingRule(undefined)}
+ onSubmit={(position) => {
+ const rule = movingRule;
+ setMovingRule(undefined);
+ if (rule) moveTo(rule, position - 1);
+ }}
+ />
+
+
+
+ {
+ if (busy === "sheet") return;
+ setSheetOpen(next);
+ if (!next) {
+ setEditingRule(undefined);
+ setSheetTemplate(undefined);
+ }
+ }}
+ onSave={saveRule}
+ />
+
+ !open && setConfirmDelete(undefined)}>
+
+
+
+ {confirmDelete && confirmDelete.length > 1
+ ? t("tools:network_rules_confirm_bulk_delete_title", { count: confirmDelete.length })
+ : t("tools:network_rules_confirm_delete_title")}
+
+ {t("tools:network_rules_confirm_delete_description")}
+
+
+ setConfirmDelete(undefined)}>
+ {t("tools:network_rules_cancel")}
+
+ {
+ const rules = confirmDelete;
+ setConfirmDelete(undefined);
+ if (rules) void deleteRules(rules);
+ }}
+ >
+ {t("tools:network_rules_confirm_delete_action")}
+
+
+
+
+
+ !open && setConfirmAllSites(undefined)}>
+
+
+ {t("tools:network_rules_confirm_all_sites_title")}
+ {t("tools:network_rules_confirm_all_sites_description")}
+
+
+ setConfirmAllSites(undefined)}>
+ {t("tools:network_rules_cancel")}
+
+ {
+ const pending = confirmAllSites;
+ setConfirmAllSites(undefined);
+ pending?.run();
+ }}
+ >
+ {t("tools:network_rules_confirm")}
+
+
+
+
+
+ );
+}
+
+function MoveToDialog({
+ rule,
+ total,
+ currentPosition,
+ onClose,
+ onSubmit,
+}: {
+ rule: NetworkRule | undefined;
+ total: number;
+ currentPosition: number;
+ onClose: () => void;
+ onSubmit: (position: number) => void;
+}) {
+ const { t } = useTranslation();
+ const [value, setValue] = useState(`${currentPosition}`);
+ const position = Number.parseInt(value, 10);
+ const valid = Number.isInteger(position) && position >= 1 && position <= total;
+
+ return (
+
+ );
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/rules.ts b/src/pages/options/routes/Tools/NetworkRules/rules.ts
new file mode 100644
index 000000000..5731a5461
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/rules.ts
@@ -0,0 +1,107 @@
+import type {
+ NetworkRule,
+ NetworkRuleActionType,
+ NetworkRuleCondition,
+ NetworkRuleState,
+} from "@App/app/repo/network_rule";
+
+export const NETWORK_RULES_PAGE_SIZE = 20;
+
+export type StatusFilter = "all" | "enabled" | "disabled";
+export type ActionFilter = "all" | NetworkRuleActionType;
+
+export const ACTION_FILTER_OPTIONS: NetworkRuleActionType[] = [
+ "removeResponseHeaders",
+ "modifyRequestHeaders",
+ "modifyResponseHeaders",
+ "block",
+ "redirect",
+ "allow",
+];
+
+/** 顺序数组是优先级的唯一来源,rules 数组的下标没有语义。 */
+export function orderedRules(rules: NetworkRule[], order: string[]): NetworkRule[] {
+ const byId = new Map(rules.map((rule) => [rule.id, rule]));
+ return order.flatMap((id) => {
+ const rule = byId.get(id);
+ return rule ? [rule] : [];
+ });
+}
+
+export function ruleDomains(rule: NetworkRule): string[] {
+ return rule.condition.requestDomains ?? [];
+}
+
+export type RuleFilters = { query: string; action: ActionFilter; status: StatusFilter };
+
+export function filtersActive({ query, action, status }: RuleFilters): boolean {
+ return query.trim() !== "" || action !== "all" || status !== "all";
+}
+
+export function filterRules(rules: NetworkRule[], { query, action, status }: RuleFilters): NetworkRule[] {
+ const needle = query.trim().toLowerCase();
+ return rules.filter((rule) => {
+ if (action !== "all" && rule.action.type !== action) return false;
+ if (status !== "all" && rule.enabled !== (status === "enabled")) return false;
+ if (!needle) return true;
+ if (rule.name.toLowerCase().includes(needle)) return true;
+ if (rule.condition.urlFilter?.toLowerCase().includes(needle)) return true;
+ return ruleDomains(rule).some((domain) => domain.toLowerCase().includes(needle));
+ });
+}
+
+/** 把一条规则移动到完整顺序中的目标位次,其余规则相对次序不变。 */
+export function moveRuleTo(order: string[], id: string, targetIndex: number): string[] {
+ const rest = order.filter((current) => current !== id);
+ if (rest.length === order.length) return order;
+ const index = Math.min(Math.max(targetIndex, 0), rest.length);
+ return [...rest.slice(0, index), id, ...rest.slice(index)];
+}
+
+export function enabledCount(state: NetworkRuleState): number {
+ return state.rules.filter((rule) => rule.enabled).length;
+}
+
+/** 「所有网站」在条件里只有这一种写法,列表、编辑与高危确认都以它判定。 */
+export const ALL_SITES_URL_FILTER = "*";
+
+export function isAllSitesCondition(condition: NetworkRuleCondition): boolean {
+ return condition.urlFilter === ALL_SITES_URL_FILTER;
+}
+
+/**
+ * DNR 把 modifyHeaders / redirect / allow 归为 unsafe 规则,它们的配额池比 block 所在的总池小得多,
+ * 所以数量提示必须按动作类型分别算。三种改头动作都编译成 modifyHeaders,同属 unsafe。
+ */
+const UNSAFE_ACTION_TYPES: NetworkRuleActionType[] = [
+ "removeResponseHeaders",
+ "modifyRequestHeaders",
+ "modifyResponseHeaders",
+ "redirect",
+ "allow",
+];
+
+export type NetworkRuleQuotaUsage = { total: number; unsafe: number };
+
+/** 只有启用的规则会被编译成 DNR 规则,停用的不占配额。 */
+export function quotaUsage(rules: NetworkRule[]): NetworkRuleQuotaUsage {
+ const active = rules.filter((rule) => rule.enabled);
+ return {
+ total: active.length,
+ unsafe: active.filter((rule) => UNSAFE_ACTION_TYPES.includes(rule.action.type)).length,
+ };
+}
+
+export type NetworkRuleQuotaLimits = { total?: number; unsafe?: number };
+
+/**
+ * 配额上限只从运行时常量读,不写死数字:Chrome 的总池与 unsafe 池是两个常量,
+ * Firefox 不区分 unsafe,只暴露动态与会话规则共用的那一个。
+ */
+export function quotaLimits(): NetworkRuleQuotaLimits {
+ const dnr: Partial = chrome.declarativeNetRequest;
+ return {
+ total: dnr.MAX_NUMBER_OF_DYNAMIC_RULES ?? dnr.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES,
+ unsafe: dnr.MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES,
+ };
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/tabs.test.ts b/src/pages/options/routes/Tools/NetworkRules/tabs.test.ts
new file mode 100644
index 000000000..1295940f3
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/tabs.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { NetworkRuleCondition } from "@App/app/repo/network_rule";
+import { reloadRuleTabs } from "./tabs";
+
+const query = vi.fn();
+const reload = vi.fn();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ query.mockResolvedValue([]);
+ reload.mockResolvedValue(undefined);
+ Object.assign(chrome.tabs, { query, reload });
+});
+afterEach(() => vi.restoreAllMocks());
+
+function patternsOf(): string[] {
+ return (query.mock.calls[0][0] as chrome.tabs.QueryInfo).url as string[];
+}
+
+describe("保存成功后的刷新标签页", () => {
+ it("只查规则域名及其子域名的标签,不按窗口把当前页一起刷掉", async () => {
+ const condition: NetworkRuleCondition = { requestDomains: ["example.com"] };
+ query.mockResolvedValue([{ id: 7 }, { id: 9 }]);
+
+ await reloadRuleTabs([condition]);
+
+ expect(patternsOf()).toEqual(["*://example.com/*", "*://*.example.com/*"]);
+ // active / currentWindow 都不该出现:范围由规则本身决定,而不是由哪个窗口在前台决定。
+ expect(query.mock.calls[0][0]).toEqual({ url: ["*://example.com/*", "*://*.example.com/*"] });
+ expect(reload.mock.calls.map((call) => call[0])).toEqual([7, 9]);
+ });
+
+ it("多条规则的域名合并去重", async () => {
+ await reloadRuleTabs([{ requestDomains: ["a.com", "b.com"] }, { requestDomains: ["b.com"] }]);
+
+ expect(patternsOf()).toEqual(["*://a.com/*", "*://*.a.com/*", "*://b.com/*", "*://*.b.com/*"]);
+ });
+
+ it("「所有网站」规则无从收窄,回落到全部网页标签", async () => {
+ await reloadRuleTabs([{ requestDomains: ["a.com"] }, { urlFilter: "*" }]);
+
+ expect(patternsOf()).toEqual(["http://*/*", "https://*/*"]);
+ });
+
+ it("拿不到 id 的标签跳过,不会用 undefined 去调用 reload", async () => {
+ query.mockResolvedValue([{ id: undefined }, { id: 3 }]);
+
+ await reloadRuleTabs([{ requestDomains: ["a.com"] }]);
+
+ expect(reload.mock.calls.map((call) => call[0])).toEqual([3]);
+ });
+});
diff --git a/src/pages/options/routes/Tools/NetworkRules/tabs.ts b/src/pages/options/routes/Tools/NetworkRules/tabs.ts
new file mode 100644
index 000000000..fdfdf3531
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/tabs.ts
@@ -0,0 +1,24 @@
+import type { NetworkRuleCondition } from "@App/app/repo/network_rule";
+
+const ALL_WEB_PAGES = ["http://*/*", "https://*/*"];
+
+/** requestDomains 覆盖域名本身与它的子域名,两条匹配式缺一不可。 */
+function tabPatterns(conditions: NetworkRuleCondition[]): string[] {
+ const domains = new Set();
+ for (const condition of conditions) {
+ // 「所有网站」等没有域名限制的规则对任何页面都可能生效,范围无从收窄。
+ if (!condition.requestDomains?.length) return ALL_WEB_PAGES;
+ for (const domain of condition.requestDomains) domains.add(domain);
+ }
+ return [...domains].flatMap((domain) => [`*://${domain}/*`, `*://*.${domain}/*`]);
+}
+
+/**
+ * DNR 只影响改动之后发出的请求,已打开的页面不会自己变化,所以保存成功要给一个刷新入口。
+ * 范围由改动波及的规则域名决定,而不是「每个窗口的当前标签」:后者会刷掉用户正在别的窗口里
+ * 填写、与这条规则毫无关系的页面。扩展自身页面不在 http(s) 匹配式内,不会被刷掉。
+ */
+export async function reloadRuleTabs(conditions: NetworkRuleCondition[]): Promise {
+ const tabs = await chrome.tabs.query({ url: tabPatterns(conditions) });
+ await Promise.all(tabs.flatMap((tab) => (tab.id === undefined ? [] : [chrome.tabs.reload(tab.id)])));
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/templates.ts b/src/pages/options/routes/Tools/NetworkRules/templates.ts
new file mode 100644
index 000000000..095339d85
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/templates.ts
@@ -0,0 +1,245 @@
+import {
+ CSP_RESPONSE_HEADERS,
+ X_FRAME_OPTIONS_HEADER,
+ cspRemovalAction,
+ isDeniedRequestHeader,
+ isValidHeaderName,
+ type NetworkRuleAction,
+ type NetworkRuleActionType,
+ type NetworkRuleHeaderEdit,
+ type NetworkRuleHeaderOperation,
+} from "@App/app/repo/network_rule";
+import type { NetworkRuleResourceType } from "@App/pkg/utils/network_rule_condition";
+
+export const RULE_TEMPLATE_IDS = [
+ "csp",
+ "userAgent",
+ "referer",
+ "responseHeaders",
+ "block",
+ "redirect",
+ "custom",
+] as const;
+export type RuleTemplateId = (typeof RULE_TEMPLATE_IDS)[number];
+
+export const USER_AGENT_HEADER = "user-agent";
+
+export const USER_AGENT_PRESETS = {
+ iphone:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ android:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
+ windows:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
+};
+export const REFERER_HEADER = "referer";
+
+/** 「移除 CSP」只作用于文档请求,页面内的子资源不携带 CSP 响应头。 */
+export const CSP_RESOURCE_TYPES: NetworkRuleResourceType[] = ["main_frame", "sub_frame"];
+
+export type HeaderDraft = { header: string; operation: NetworkRuleHeaderOperation; value: string };
+
+export type ActionDraft = {
+ /** 只有「自定义」模板会让用户改动它,其余模板由模板本身固定。 */
+ actionType: NetworkRuleActionType;
+ includeXFrameOptions: boolean;
+ userAgent: string;
+ refererOperation: Extract;
+ referer: string;
+ headers: HeaderDraft[];
+ redirectUrl: string;
+};
+
+export type DraftErrorKey =
+ | "required"
+ | "header_name_invalid"
+ | "header_denied"
+ | "header_value_required"
+ | "redirect_url_invalid";
+
+export type ActionDraftErrors = { headers: (DraftErrorKey | undefined)[]; action?: DraftErrorKey };
+
+const EMPTY_HEADER: HeaderDraft = { header: "", operation: "set", value: "" };
+
+export const ACTION_TYPE_BY_TEMPLATE: Record, NetworkRuleActionType> = {
+ csp: "removeResponseHeaders",
+ userAgent: "modifyRequestHeaders",
+ referer: "modifyRequestHeaders",
+ responseHeaders: "modifyResponseHeaders",
+ block: "block",
+ redirect: "redirect",
+};
+
+export function emptyActionDraft(template: RuleTemplateId): ActionDraft {
+ const actionType = template === "custom" ? "block" : ACTION_TYPE_BY_TEMPLATE[template];
+ return {
+ actionType,
+ includeXFrameOptions: false,
+ userAgent: "",
+ refererOperation: "set",
+ referer: "",
+ headers: [{ ...EMPTY_HEADER }],
+ redirectUrl: "",
+ };
+}
+
+/** 动作里带值的头改写才需要值列;移除响应头只填头名。 */
+export function editsHeaderList(actionType: NetworkRuleActionType): boolean {
+ return (
+ actionType === "modifyRequestHeaders" ||
+ actionType === "modifyResponseHeaders" ||
+ actionType === "removeResponseHeaders"
+ );
+}
+
+export function editsRequestHeaders(actionType: NetworkRuleActionType): boolean {
+ return actionType === "modifyRequestHeaders";
+}
+
+function sameHeaderSet(headers: string[], expected: string[]): boolean {
+ return headers.length === expected.length && expected.every((header) => headers.includes(header));
+}
+
+export function detectTemplate(action: NetworkRuleAction): RuleTemplateId {
+ switch (action.type) {
+ case "removeResponseHeaders":
+ if (sameHeaderSet(action.headers, CSP_RESPONSE_HEADERS)) return "csp";
+ if (sameHeaderSet(action.headers, [...CSP_RESPONSE_HEADERS, X_FRAME_OPTIONS_HEADER])) return "csp";
+ return "custom";
+ case "modifyRequestHeaders": {
+ const [edit] = action.headers;
+ if (action.headers.length !== 1) return "custom";
+ if (edit.header === USER_AGENT_HEADER && edit.operation === "set") return "userAgent";
+ if (edit.header === REFERER_HEADER && edit.operation !== "append") return "referer";
+ return "custom";
+ }
+ case "modifyResponseHeaders":
+ return "responseHeaders";
+ case "block":
+ return "block";
+ case "redirect":
+ return "redirect";
+ case "allow":
+ return "custom";
+ }
+}
+
+export function actionDraftFrom(action: NetworkRuleAction): ActionDraft {
+ const draft = emptyActionDraft(detectTemplate(action));
+ draft.actionType = action.type;
+ switch (action.type) {
+ case "removeResponseHeaders":
+ draft.includeXFrameOptions = action.headers.includes(X_FRAME_OPTIONS_HEADER);
+ draft.headers = action.headers.map((header) => ({ header, operation: "remove", value: "" }));
+ return draft;
+ case "modifyRequestHeaders":
+ case "modifyResponseHeaders": {
+ const [edit] = action.headers;
+ if (action.type === "modifyRequestHeaders" && action.headers.length === 1) {
+ if (edit.header === USER_AGENT_HEADER) draft.userAgent = edit.value ?? "";
+ if (edit.header === REFERER_HEADER) {
+ draft.refererOperation = edit.operation === "remove" ? "remove" : "set";
+ draft.referer = edit.value ?? "";
+ }
+ }
+ draft.headers = action.headers.map(({ header, operation, value }) => ({
+ header,
+ operation,
+ value: value ?? "",
+ }));
+ return draft;
+ }
+ case "redirect":
+ draft.redirectUrl = action.url;
+ return draft;
+ default:
+ return draft;
+ }
+}
+
+function normalizeHeaderName(input: string): string {
+ return input.trim().toLowerCase();
+}
+
+function headerRowError(row: HeaderDraft, actionType: NetworkRuleActionType): DraftErrorKey | undefined {
+ const header = normalizeHeaderName(row.header);
+ if (!header) return "required";
+ if (!isValidHeaderName(header)) return "header_name_invalid";
+ if (editsRequestHeaders(actionType) && isDeniedRequestHeader(header)) return "header_denied";
+ if (actionType !== "removeResponseHeaders" && row.operation !== "remove" && !row.value.trim()) {
+ return "header_value_required";
+ }
+ return undefined;
+}
+
+export function validateActionDraft(template: RuleTemplateId, draft: ActionDraft): ActionDraftErrors {
+ const actionType = template === "custom" ? draft.actionType : ACTION_TYPE_BY_TEMPLATE[template];
+ if (template === "userAgent") return { headers: [], action: draft.userAgent.trim() ? undefined : "required" };
+ if (template === "referer") {
+ const missing = draft.refererOperation === "set" && !draft.referer.trim();
+ return { headers: [], action: missing ? "required" : undefined };
+ }
+ if (actionType === "redirect") {
+ const url = draft.redirectUrl.trim();
+ if (!url) return { headers: [], action: "required" };
+ try {
+ const parsed = new URL(url);
+ const supported = parsed.protocol === "http:" || parsed.protocol === "https:";
+ return { headers: [], action: supported ? undefined : "redirect_url_invalid" };
+ } catch {
+ return { headers: [], action: "redirect_url_invalid" };
+ }
+ }
+ if (template === "csp" || !editsHeaderList(actionType)) return { headers: [] };
+ return { headers: draft.headers.map((row) => headerRowError(row, actionType)) };
+}
+
+export function hasActionDraftError({ headers, action }: ActionDraftErrors): boolean {
+ return action !== undefined || headers.some((error) => error !== undefined);
+}
+
+function headerEdits(draft: ActionDraft): NetworkRuleHeaderEdit[] {
+ return draft.headers.map(({ header, operation, value }) =>
+ operation === "remove"
+ ? { header: normalizeHeaderName(header), operation }
+ : { header: normalizeHeaderName(header), operation, value: value.trim() }
+ );
+}
+
+export function buildAction(template: RuleTemplateId, draft: ActionDraft): NetworkRuleAction {
+ const actionType = template === "custom" ? draft.actionType : ACTION_TYPE_BY_TEMPLATE[template];
+ switch (template) {
+ case "csp":
+ return cspRemovalAction(draft.includeXFrameOptions);
+ case "userAgent":
+ return {
+ type: "modifyRequestHeaders",
+ headers: [{ header: USER_AGENT_HEADER, operation: "set", value: draft.userAgent.trim() }],
+ };
+ case "referer":
+ return {
+ type: "modifyRequestHeaders",
+ headers: [
+ draft.refererOperation === "remove"
+ ? { header: REFERER_HEADER, operation: "remove" }
+ : { header: REFERER_HEADER, operation: "set", value: draft.referer.trim() },
+ ],
+ };
+ default:
+ break;
+ }
+ switch (actionType) {
+ case "removeResponseHeaders":
+ return { type: "removeResponseHeaders", headers: draft.headers.map((row) => normalizeHeaderName(row.header)) };
+ case "modifyRequestHeaders":
+ return { type: "modifyRequestHeaders", headers: headerEdits(draft) };
+ case "modifyResponseHeaders":
+ return { type: "modifyResponseHeaders", headers: headerEdits(draft) };
+ case "redirect":
+ return { type: "redirect", url: draft.redirectUrl.trim() };
+ case "allow":
+ return { type: "allow" };
+ case "block":
+ return { type: "block" };
+ }
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/test-helpers.ts b/src/pages/options/routes/Tools/NetworkRules/test-helpers.ts
new file mode 100644
index 000000000..722f691c5
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/test-helpers.ts
@@ -0,0 +1,14 @@
+import { vi } from "vitest";
+import { notify } from "@App/pages/components/ui/toast";
+
+const NOTIFY_METHODS = ["success", "error", "info", "warning"] as const;
+
+/**
+ * ui project 以 isolate:false 运行,同一 worker 内模块缓存跨测试文件共享:谁先加载被测组件,
+ * 组件里的 `notify` 就永久绑定到那个文件 vi.mock 工厂产出的对象,后续文件再 mock 也收不到调用。
+ * 所以不替换模块,改为在真实 notify 单例上打桩;调用方须在 afterEach 里 vi.restoreAllMocks() 还原,
+ * 否则桩会随单例泄漏给同 worker 的其他测试文件。
+ */
+export function stubNotify() {
+ for (const method of NOTIFY_METHODS) vi.spyOn(notify, method).mockReturnValue("");
+}
diff --git a/src/pages/options/routes/Tools/NetworkRules/useNetworkRuleSnapshot.ts b/src/pages/options/routes/Tools/NetworkRules/useNetworkRuleSnapshot.ts
new file mode 100644
index 000000000..d5c8e3012
--- /dev/null
+++ b/src/pages/options/routes/Tools/NetworkRules/useNetworkRuleSnapshot.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
+import {
+ parseNetworkRuleError,
+ type NetworkRuleClient,
+ type NetworkRuleServiceError,
+} from "@App/app/service/service_worker/client";
+import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+import { subscribeMessage } from "@App/pages/store/global";
+
+export type NetworkRuleSnapshotState = {
+ snapshot: NetworkRuleSnapshot | undefined;
+ setSnapshot: Dispatch>;
+ loading: boolean;
+ loadError: NetworkRuleServiceError | undefined;
+ setLoadError: Dispatch>;
+};
+
+/**
+ * 列表页与 Tools 摘要卡读的是同一份权威状态:首次拉取 + 订阅 service worker 的广播。
+ * 广播可能乱序到达,revision 更旧的一律丢弃。
+ */
+export function useNetworkRuleSnapshot(client: NetworkRuleClient): NetworkRuleSnapshotState {
+ const [snapshot, setSnapshot] = useState();
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState();
+
+ useEffect(() => {
+ let active = true;
+ void client
+ .getState()
+ .then((next) => active && setSnapshot(next))
+ .catch((error: unknown) => active && setLoadError(parseNetworkRuleError(error)))
+ .finally(() => active && setLoading(false));
+ const unsubscribe = subscribeMessage("networkRule/stateChanged", (next) => {
+ setSnapshot((current) => (current && next.state.revision < current.state.revision ? current : next));
+ });
+ return () => {
+ active = false;
+ unsubscribe();
+ };
+ }, [client]);
+
+ return { snapshot, setSnapshot, loading, loadError, setLoadError };
+}
diff --git a/src/pages/options/routes/Tools/categories.ts b/src/pages/options/routes/Tools/categories.ts
index c7b306ac8..e4cb7221b 100644
--- a/src/pages/options/routes/Tools/categories.ts
+++ b/src/pages/options/routes/Tools/categories.ts
@@ -1,4 +1,4 @@
-import { HardDrive, Cloud, CalendarClock, Database, Terminal, PlugZap } from "lucide-react";
+import { HardDrive, Cloud, CalendarClock, Database, Network, Terminal, PlugZap } from "lucide-react";
import type { TFunction } from "i18next";
import type { SettingsCategory } from "../../layout/SettingsLayout";
import { isFirefox } from "@App/pkg/utils/utils";
@@ -9,6 +9,7 @@ export function getToolsCategories(t: TFunction): SettingsCategory[] {
{ id: "cloud-backup", icon: Cloud, label: t("tools:cloud_backup") },
{ id: "auto-backup", icon: CalendarClock, label: t("tools:auto_backup") },
{ id: "data-migration", icon: Database, label: t("tools:data_migration") },
+ { id: "network-rules", icon: Network, label: t("tools:network_rules_title") },
{ id: "dev-tools", icon: Terminal, label: t("tools:development_tool") },
// 外部接入尚未在 Firefox MV3 上构建/验证,不在 Firefox 提供入口,避免未测试配置被静默启用。
...(!isFirefox() ? [{ id: "external-access", icon: PlugZap, label: t("external_access:section_title") }] : []),
diff --git a/src/pages/options/routes/Tools/index.tsx b/src/pages/options/routes/Tools/index.tsx
index acf50ab01..ed78d7992 100644
--- a/src/pages/options/routes/Tools/index.tsx
+++ b/src/pages/options/routes/Tools/index.tsx
@@ -5,6 +5,7 @@ import { CloudBackupSection } from "./sections/CloudBackupSection";
import { AutoBackupSection } from "./sections/AutoBackupSection";
import { MigrationSection } from "./sections/MigrationSection";
import { DevToolsSection } from "./sections/DevToolsSection";
+import { NetworkRulesSection } from "./sections/NetworkRulesSection";
import { ExternalAccessSection } from "./sections/ExternalAccessSection";
import { isFirefox } from "@App/pkg/utils/utils";
import { useTranslation } from "react-i18next";
@@ -19,6 +20,7 @@ export default function Tools() {
+
{!isFirefox() && }
>
diff --git a/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx b/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx
new file mode 100644
index 000000000..5fc54aadf
--- /dev/null
+++ b/src/pages/options/routes/Tools/sections/NetworkRulesSection.test.tsx
@@ -0,0 +1,192 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { renderWithThemeRouter } from "@Tests/renderWithThemeRouter";
+import { NetworkRuleAmbiguousResponseError, type NetworkRuleClient } from "@App/app/service/service_worker/client";
+import { cspRemovalAction, type NetworkRule } from "@App/app/repo/network_rule";
+import type { NetworkRuleSnapshot } from "@App/app/service/service_worker/network_rule";
+import { extensionEnv } from "@App/app/service/extension/extension_env";
+import { NetworkRulesSection } from "./NetworkRulesSection";
+
+beforeAll(() => initTestLanguage("zh-CN"));
+beforeEach(() => {
+ mockMatchMedia();
+ vi.clearAllMocks();
+});
+afterEach(() => {
+ extensionEnv.inIncognitoContext = false;
+ extensionEnv.incognitoMode = "split";
+ cleanup();
+});
+
+function rule(index: number, over: Partial = {}): NetworkRule {
+ return {
+ id: `r${index}`,
+ name: `规则 ${index}`,
+ enabled: true,
+ condition: { requestDomains: [`s${index}.example.com`], resourceTypes: ["main_frame"] },
+ action: cspRemovalAction(),
+ createdAt: 1,
+ updatedAt: index,
+ ...over,
+ };
+}
+
+function snapshot(rules: NetworkRule[], over: Partial = {}): NetworkRuleSnapshot {
+ return {
+ state: { schemaVersion: 1, revision: 1, masterEnabled: true, rules, order: rules.map((r) => r.id) },
+ apply: { state: "applied", revision: 1, appliedAt: 1 },
+ ...over,
+ };
+}
+
+function clientFor(current: NetworkRuleSnapshot, overrides: Partial = {}): NetworkRuleClient {
+ return {
+ getState: vi.fn().mockResolvedValue(current),
+ createRule: vi.fn(),
+ updateRule: vi.fn(),
+ deleteRule: vi.fn(),
+ setRuleEnabled: vi.fn(),
+ setMasterEnabled: vi.fn(),
+ reorderRules: vi.fn(),
+ retryApply: vi.fn(),
+ ...overrides,
+ } as unknown as NetworkRuleClient;
+}
+
+function renderCard(client: NetworkRuleClient) {
+ return renderWithThemeRouter(
+
+ () => {}} client={client} />} />
+ {"规则列表页"}
} />
+ ,
+ { initialEntries: ["/tools"] }
+ );
+}
+
+function manyRules(count: number): NetworkRule[] {
+ return Array.from({ length: count }, (_, index) => rule(index));
+}
+
+describe("Tools 网络规则摘要卡", () => {
+ it("规则数从 3 条涨到 50 条,卡片结构不变——预览恒为两条,高度不随规则数增长", async () => {
+ const small = renderCard(clientFor(snapshot(manyRules(3))));
+ await screen.findByTestId("network-rules-summary");
+ const smallCard = screen.getByTestId("network-rules-summary");
+ const smallNodes = smallCard.querySelectorAll("*").length;
+ expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2);
+ small.unmount();
+
+ renderCard(clientFor(snapshot(manyRules(50))));
+ await screen.findByTestId("network-rules-summary");
+ expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2);
+ expect(screen.getByTestId("network-rules-summary").querySelectorAll("*").length).toBe(smallNodes);
+ });
+
+ it("摘要卡不承载增删改:没有新建/编辑/删除入口,也没有逐条的启用开关", async () => {
+ renderCard(clientFor(snapshot(manyRules(3))));
+ await screen.findByTestId("network-rules-summary");
+
+ for (const name of ["新建规则", "编辑", "删除", "更多操作"]) {
+ expect(screen.queryByRole("button", { name })).toBeNull();
+ }
+ expect(screen.getAllByRole("switch")).toHaveLength(1);
+ expect(screen.getByRole("switch")).toHaveAccessibleName("总开关");
+ });
+
+ it("预览显示最近修改的两条规则与其余条数", async () => {
+ renderCard(clientFor(snapshot(manyRules(5))));
+ await screen.findByTestId("network-rules-summary");
+ const names = screen.getAllByTestId("network-rules-preview-row").map((row) => row.textContent ?? "");
+ expect(names[0]).toContain("规则 4");
+ expect(names[1]).toContain("规则 3");
+ expect(screen.getByText("另有 3 条…")).toBeInTheDocument();
+ });
+
+ it("「管理规则」是进入 /tools/network-rules 的入口", async () => {
+ renderCard(clientFor(snapshot(manyRules(3))));
+ await screen.findByTestId("network-rules-summary");
+ fireEvent.click(screen.getByRole("button", { name: "管理规则" }));
+ expect(await screen.findByText("规则列表页")).toBeInTheDocument();
+ });
+
+ it("「测试匹配」打开纯前端模拟对话框", async () => {
+ renderCard(clientFor(snapshot(manyRules(3))));
+ await screen.findByTestId("network-rules-summary");
+ fireEvent.click(screen.getByRole("button", { name: "测试匹配" }));
+ expect(await screen.findByLabelText("网址")).toBeInTheDocument();
+ });
+
+ it("加载中显示骨架而不是规则", () => {
+ let resolve: (value: NetworkRuleSnapshot) => void = () => {};
+ const client = clientFor(snapshot([]), {
+ getState: vi.fn(() => new Promise((r) => (resolve = r))),
+ } as unknown as Partial);
+ renderCard(client);
+ expect(screen.getByTestId("network-rules-loading")).toBeInTheDocument();
+ resolve(snapshot([]));
+ });
+
+ it("空态说明还没有规则", async () => {
+ renderCard(clientFor(snapshot([])));
+ expect(await screen.findByText("还没有网络规则")).toBeInTheDocument();
+ expect(screen.getByText("0 条启用中 · 共 0 条规则")).toBeInTheDocument();
+ });
+
+ it("应用失败时保留已保存的规则并提供重试", async () => {
+ const failed = snapshot(manyRules(3), {
+ apply: { state: "error", code: "dnr_apply_failed", desiredRevision: 1, message: "Rule limit exceeded" },
+ });
+ const retryApply = vi.fn().mockResolvedValue({ ...snapshot(manyRules(3)), outcome: "applied" });
+ renderCard(clientFor(failed, { retryApply } as unknown as Partial));
+
+ expect(await screen.findByText("规则未能应用到浏览器")).toBeInTheDocument();
+ expect(screen.getByText("未生效")).toBeInTheDocument();
+ expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2);
+ fireEvent.click(screen.getByRole("button", { name: "重试" }));
+ await waitFor(() => expect(retryApply).toHaveBeenCalled());
+ });
+
+ it("总开关关闭时规则保留并置灰", async () => {
+ renderCard(clientFor(snapshot(manyRules(3), { state: { ...snapshot(manyRules(3)).state, masterEnabled: false } })));
+ expect(await screen.findByText("所有规则已暂停")).toBeInTheDocument();
+ expect(screen.getByText("已暂停")).toBeInTheDocument();
+ expect(screen.getByTestId("network-rules-preview")).toHaveClass("opacity-60");
+ expect(screen.getAllByTestId("network-rules-preview-row")).toHaveLength(2);
+ });
+
+ it("隐身上下文与普通窗口一视同仁:照常读取状态、可管理,且没有隐身提示", async () => {
+ extensionEnv.inIncognitoContext = true;
+ const client = clientFor(snapshot(manyRules(3)));
+ renderCard(client);
+ expect(await screen.findByTestId("network-rules-summary")).toBeInTheDocument();
+ expect(client.getState).toHaveBeenCalled();
+ expect(screen.getByRole("switch", { name: "总开关" })).toBeInTheDocument();
+ expect(screen.queryByText(/请在普通窗口中管理网络规则/)).toBeNull();
+ });
+
+ it("总开关关闭时测试匹配照常给出命中,并说明规则被暂停", async () => {
+ const paused = snapshot(manyRules(3));
+ paused.state.masterEnabled = false;
+ renderCard(clientFor(paused));
+ await screen.findByTestId("network-rules-summary");
+
+ fireEvent.click(screen.getByRole("button", { name: "测试匹配" }));
+ fireEvent.change(await screen.findByLabelText("网址"), { target: { value: "https://s1.example.com/page" } });
+
+ expect(screen.getAllByTestId("match-test-hit")).toHaveLength(1);
+ expect(screen.getByTestId("match-test-outcome")).not.toHaveTextContent("无命中");
+ expect(within(screen.getByRole("dialog")).getByText(/总开关已关闭/)).toBeInTheDocument();
+ });
+
+ it("总开关写入失败时提示且不改动本地状态", async () => {
+ const setMasterEnabled = vi.fn().mockRejectedValue(new NetworkRuleAmbiguousResponseError());
+ renderCard(clientFor(snapshot(manyRules(3)), { setMasterEnabled } as unknown as Partial));
+ await screen.findByTestId("network-rules-summary");
+ fireEvent.click(screen.getByRole("switch"));
+ await waitFor(() => expect(setMasterEnabled).toHaveBeenCalled());
+ expect(screen.getByRole("switch")).toBeChecked();
+ });
+});
diff --git a/src/pages/options/routes/Tools/sections/NetworkRulesSection.tsx b/src/pages/options/routes/Tools/sections/NetworkRulesSection.tsx
new file mode 100644
index 000000000..777d4b213
--- /dev/null
+++ b/src/pages/options/routes/Tools/sections/NetworkRulesSection.tsx
@@ -0,0 +1,271 @@
+import { useMemo, useState, type ReactNode } from "react";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle, Loader2, Network, PauseCircle } from "lucide-react";
+import type { NetworkRule } from "@App/app/repo/network_rule";
+import {
+ NetworkRuleAmbiguousResponseError,
+ parseNetworkRuleError,
+ type NetworkRuleClient,
+} from "@App/app/service/service_worker/client";
+import type { NetworkRuleMutationResult } from "@App/app/service/service_worker/network_rule";
+import { networkRuleClient } from "@App/pages/store/features/script";
+import { Badge } from "@App/pages/components/ui/badge";
+import { Button } from "@App/pages/components/ui/button";
+import { SettingCard } from "@App/pages/options/components/SettingCard";
+import { Skeleton } from "@App/pages/components/ui/skeleton";
+import { Switch } from "@App/pages/components/ui/switch";
+import { notify } from "@App/pages/components/ui/toast";
+import { cn } from "@App/pkg/utils/cn";
+import MatchTestDialog from "../NetworkRules/MatchTestDialog";
+import { ActionBadge, useActionLabels } from "../NetworkRules/RuleParts";
+import { enabledCount, isAllSitesCondition, orderedRules, ruleDomains } from "../NetworkRules/rules";
+import { useNetworkRuleSnapshot } from "../NetworkRules/useNetworkRuleSnapshot";
+
+/** 预览恒为两条:卡片高度必须与规则数无关,滚动监听的固定触发线才准。 */
+const PREVIEW_LIMIT = 2;
+
+type NetworkRulesSectionProps = {
+ register: (id: string) => (el: HTMLElement | null) => void;
+ client?: NetworkRuleClient;
+};
+
+function recentRules(rules: NetworkRule[], order: string[]): NetworkRule[] {
+ return [...orderedRules(rules, order)].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, PREVIEW_LIMIT);
+}
+
+function PreviewRow({ rule }: { rule: NetworkRule }) {
+ const { t } = useTranslation();
+ const actionLabels = useActionLabels();
+ const domains = ruleDomains(rule);
+ return (
+
+
{rule.name}
+
+
+ {isAllSitesCondition(rule.condition) ? t("tools:network_rules_all_websites") : domains.join(" · ")}
+
+
+ );
+}
+
+function Banner({
+ tone,
+ icon: Icon,
+ title,
+ description,
+ action,
+}: {
+ tone: "warning" | "destructive";
+ icon: typeof AlertTriangle;
+ title: string;
+ description?: string;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+ {description &&
{description}
}
+
+ {action}
+
+ );
+}
+
+export function NetworkRulesSection({ register, client: injectedClient }: NetworkRulesSectionProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const client = useMemo(() => injectedClient ?? networkRuleClient, [injectedClient]);
+ const { snapshot, setSnapshot, loading, loadError, setLoadError } = useNetworkRuleSnapshot(client);
+ const [busy, setBusy] = useState();
+ const [matchTestOpen, setMatchTestOpen] = useState(false);
+
+ const state = snapshot?.state;
+ const total = state?.rules.length ?? 0;
+ const enabled = state ? enabledCount(state) : 0;
+ const preview = useMemo(() => (state ? recentRules(state.rules, state.order) : []), [state]);
+ const applyFailed = snapshot?.apply.state === "error";
+ const paused = state !== undefined && !state.masterEnabled;
+
+ const applyResult = (result: NetworkRuleMutationResult) => {
+ setSnapshot(result);
+ if (result.outcome !== "applied" || result.apply.state !== "applied") {
+ notify.error(t("tools:network_rules_rule_saved_apply_failed"));
+ }
+ };
+
+ // service worker 挂起时响应可能丢失,但请求已在后台生效:重新拉取权威 state 判断是否推进了 revision。
+ const settleAmbiguous = async (baseRevision: number): Promise => {
+ try {
+ const latest = await client.getState();
+ setSnapshot(latest);
+ return latest.state.revision > baseRevision;
+ } catch {
+ return false;
+ }
+ };
+
+ const setMasterEnabled = async (next: boolean) => {
+ if (!state) return;
+ const baseRevision = state.revision;
+ setBusy("master");
+ try {
+ applyResult(await client.setMasterEnabled({ baseRevision, enabled: next }));
+ } catch (error) {
+ if (error instanceof NetworkRuleAmbiguousResponseError) {
+ if (!(await settleAmbiguous(baseRevision))) notify.error(t("tools:network_rules_storage_error"));
+ return;
+ }
+ const parsed = parseNetworkRuleError(error);
+ if (parsed.snapshot) setSnapshot(parsed.snapshot);
+ notify.error(
+ parsed.code === "revision_conflict"
+ ? t("tools:network_rules_revision_conflict")
+ : t("tools:network_rules_storage_error")
+ );
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const retry = async () => {
+ setBusy("retry");
+ try {
+ applyResult(await client.retryApply());
+ setLoadError(undefined);
+ } catch (error) {
+ setLoadError(parseNetworkRuleError(error));
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const retryButton = (
+
+ );
+
+ const stateLabel = applyFailed
+ ? t("tools:network_rules_state_failed")
+ : paused
+ ? t("tools:network_rules_state_paused")
+ : t("tools:network_rules_state_active");
+
+ return (
+ void setMasterEnabled(checked)}
+ />
+ }
+ >
+ {loading && (
+
+
+
+
+ )}
+
+ {!loading && loadError && (
+
+ )}
+
+ {!loading && !loadError && snapshot && (
+
+
+ {stateLabel}
+
+ {t("tools:network_rules_summary_counts", { enabled, total })}
+
+
+
+ {applyFailed && snapshot.apply.state === "error" && (
+
+ )}
+
+ {paused && (
+
+ )}
+
+
+ {total === 0 ? (
+ <>
+
{t("tools:network_rules_empty_title")}
+
{t("tools:network_rules_empty_description")}
+ >
+ ) : (
+ <>
+ {preview.map((rule) => (
+
+ ))}
+ {total > PREVIEW_LIMIT && (
+
+ {t("tools:network_rules_preview_more", { count: total - PREVIEW_LIMIT })}
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/store/features/script.ts b/src/pages/store/features/script.ts
index 1a170f792..09f405ee7 100644
--- a/src/pages/store/features/script.ts
+++ b/src/pages/store/features/script.ts
@@ -7,6 +7,7 @@ import {
PopupClient,
ResourceClient,
RuntimeClient,
+ NetworkRuleClient,
ScriptClient,
SubscribeClient,
SynchronizeClient,
@@ -27,6 +28,7 @@ export const synchronizeClient = new SynchronizeClient(message);
export const agentClient = new AgentClient(message);
export const logClient = new LogClient(message);
export const externalAccessClient = new ExternalAccessClient(message);
+export const networkRuleClient = new NetworkRuleClient(message);
export const fetchScriptList = async () => {
return await scriptClient.getAllScripts();
diff --git a/src/pkg/utils/network_rule_condition.test.ts b/src/pkg/utils/network_rule_condition.test.ts
new file mode 100644
index 000000000..7c74aa558
--- /dev/null
+++ b/src/pkg/utils/network_rule_condition.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { MAX_RULE_DOMAINS, RuleDomainError, normalizeRuleDomain, parseRuleDomains } from "./network_rule_condition";
+
+describe("网络规则域名规范化", () => {
+ it("输入完整网址时只保存规范化 hostname", () => {
+ expect(normalizeRuleDomain(" https://Example.com:8443/a?q=1#x ")).toBe("example.com");
+ });
+
+ it("输入 IDN 时保存 punycode 并可由 requestDomains 使用", () => {
+ expect(normalizeRuleDomain("https://例子.测试")).toBe("xn--fsqu00a.xn--0zwm56d");
+ });
+
+ it("输入星号和单标签 hostname 时给出明确错误", () => {
+ expect(() => normalizeRuleDomain("*.example.com")).toThrowError(RuleDomainError);
+ expect(() => normalizeRuleDomain("localhost")).toThrowError(RuleDomainError);
+ });
+
+ it("拒绝 credentials、路径和非 HTTP(S) 地址", () => {
+ for (const value of ["https://user:pass@example.com/", "example.com/path", "chrome://settings"]) {
+ expect(() => normalizeRuleDomain(value)).toThrowError(RuleDomainError);
+ }
+ });
+
+ it("保留规范 IPv6 的方括号并移除根域点", () => {
+ expect(normalizeRuleDomain("https://[2001:db8::1]/")).toBe("[2001:db8::1]");
+ expect(normalizeRuleDomain("example.com.")).toBe("example.com");
+ });
+
+ it("同一规则的重复域名被去重并保留首次出现顺序", () => {
+ expect(parseRuleDomains("Example.com, example.com\nhttps://docs.example.com/path")).toEqual({
+ domains: ["example.com", "docs.example.com"],
+ errors: [],
+ });
+ });
+
+ it("域名条数超过单条规则上限时给出可展示的错误,而不是静默接受", () => {
+ const tooMany = Array.from({ length: MAX_RULE_DOMAINS + 1 }, (_, index) => `d${index}.example.com`).join("\n");
+ expect(parseRuleDomains(tooMany).errors).toEqual([
+ { tokenIndex: MAX_RULE_DOMAINS, input: `d${MAX_RULE_DOMAINS}.example.com`, messageKey: "domain_count_invalid" },
+ ]);
+ const atLimit = Array.from({ length: MAX_RULE_DOMAINS }, (_, index) => `d${index}.example.com`).join("\n");
+ expect(parseRuleDomains(atLimit).errors).toEqual([]);
+ });
+
+ it("规范化失败的 token 保留位置并提示输入根域名", () => {
+ expect(parseRuleDomains("*.example.com, localhost").errors).toEqual([
+ { tokenIndex: 0, input: "*.example.com", messageKey: "domain_wildcard" },
+ { tokenIndex: 1, input: "localhost", messageKey: "domain_single_label" },
+ ]);
+ });
+});
diff --git a/src/pkg/utils/network_rule_condition.ts b/src/pkg/utils/network_rule_condition.ts
new file mode 100644
index 000000000..ee5d9e55e
--- /dev/null
+++ b/src/pkg/utils/network_rule_condition.ts
@@ -0,0 +1,154 @@
+/** 单条规则的域名条数上限,DNR 条件与表单共用同一个界。 */
+export const MAX_RULE_DOMAINS = 100;
+
+export type RuleDomainMessageKey =
+ | "domain_required"
+ | "domain_invalid"
+ | "domain_credentials"
+ | "domain_wildcard"
+ | "domain_single_label"
+ | "domain_too_long"
+ | "domain_count_invalid";
+
+export class RuleDomainError extends Error {
+ constructor(public readonly messageKey: RuleDomainMessageKey) {
+ super(messageKey);
+ this.name = "RuleDomainError";
+ }
+}
+
+export type RuleDomainIssue = {
+ tokenIndex: number;
+ input: string;
+ messageKey: RuleDomainMessageKey;
+};
+
+export type RuleDomainParseResult = {
+ domains: string[];
+ errors: RuleDomainIssue[];
+};
+
+const HTTP_URL_PATTERN = /^https?:\/\//i;
+const IPV6_PATTERN = /^\[[0-9a-fA-F:.]+\]$/;
+
+function fail(messageKey: RuleDomainMessageKey): never {
+ throw new RuleDomainError(messageKey);
+}
+
+function normalizeHostname(hostname: string): string {
+ const isIpv6 = hostname.startsWith("[") && hostname.endsWith("]");
+ const normalized = (isIpv6 ? hostname : hostname.replace(/\.$/, "")).toLowerCase();
+ if (normalized.includes("*") || normalized.includes("%")) fail("domain_invalid");
+ if (!isIpv6 && !normalized.includes(".")) fail("domain_single_label");
+ if (normalized.length > 253) fail("domain_too_long");
+ return normalized;
+}
+
+/** 只接受由用户明确配置的 HTTP(S) 域名,避免把路径语法带入 DNR。 */
+export function normalizeRuleDomain(input: string): string {
+ if (typeof input !== "string") fail("domain_invalid");
+ const token = input.trim();
+ if (!token) fail("domain_required");
+ if (token.includes("\n") || token.includes("\r") || token.includes(",")) fail("domain_invalid");
+ if (token.includes("*")) fail("domain_wildcard");
+
+ let url: URL;
+ if (HTTP_URL_PATTERN.test(token)) {
+ try {
+ url = new URL(token);
+ } catch {
+ fail("domain_invalid");
+ }
+ if (url.username || url.password) fail("domain_credentials");
+ if (url.protocol !== "http:" && url.protocol !== "https:") fail("domain_invalid");
+ return normalizeHostname(url.hostname);
+ }
+
+ if (token.startsWith("//") || token.includes("/") || token.includes("?") || token.includes("#")) {
+ fail("domain_invalid");
+ }
+
+ if (token.startsWith("[") && token.endsWith("]")) {
+ if (!IPV6_PATTERN.test(token)) fail("domain_invalid");
+ try {
+ url = new URL(`http://${token}`);
+ } catch {
+ fail("domain_invalid");
+ }
+ if (url.port || url.username || url.password) fail("domain_invalid");
+ return normalizeHostname(url.hostname);
+ }
+
+ if (token.includes(":") || token.includes("@")) fail("domain_invalid");
+ try {
+ url = new URL(`http://${token}`);
+ } catch {
+ fail("domain_invalid");
+ }
+ if (url.username || url.password || url.port || url.pathname !== "/") fail("domain_invalid");
+ return normalizeHostname(url.hostname);
+}
+
+export function parseRuleDomains(input: string): RuleDomainParseResult {
+ const tokens = input.split(/[\n,]/);
+ const domains: string[] = [];
+ const errors: RuleDomainIssue[] = [];
+
+ for (const [tokenIndex, rawToken] of tokens.entries()) {
+ const token = rawToken.trim();
+ if (!token) continue;
+ try {
+ const domain = normalizeRuleDomain(token);
+ if (domains.includes(domain)) continue;
+ if (domains.length === MAX_RULE_DOMAINS) {
+ errors.push({ tokenIndex, input: token, messageKey: "domain_count_invalid" });
+ break;
+ }
+ domains.push(domain);
+ } catch (error) {
+ const messageKey = error instanceof RuleDomainError ? error.messageKey : "domain_invalid";
+ errors.push({ tokenIndex, input: token, messageKey });
+ }
+ }
+
+ if (domains.length === 0 && errors.length === 0) {
+ errors.push({ tokenIndex: 0, input: input.trim(), messageKey: "domain_required" });
+ }
+ return { domains, errors };
+}
+
+/**
+ * DNR `RuleCondition` 中开放给用户配置的资源类型与请求方法。
+ * 独立枚举而不是直接复用 chrome 类型,是为了让校验层有一个明确的输入白名单。
+ */
+export const NETWORK_RULE_RESOURCE_TYPES = [
+ "main_frame",
+ "sub_frame",
+ "stylesheet",
+ "script",
+ "image",
+ "font",
+ "object",
+ "xmlhttprequest",
+ "ping",
+ "csp_report",
+ "media",
+ "websocket",
+ "other",
+] as const;
+
+export type NetworkRuleResourceType = (typeof NETWORK_RULE_RESOURCE_TYPES)[number];
+
+export const NETWORK_RULE_REQUEST_METHODS = [
+ "connect",
+ "delete",
+ "get",
+ "head",
+ "options",
+ "patch",
+ "post",
+ "put",
+ "other",
+] as const;
+
+export type NetworkRuleRequestMethod = (typeof NETWORK_RULE_REQUEST_METHODS)[number];
diff --git a/src/pkg/utils/network_rule_match.test.ts b/src/pkg/utils/network_rule_match.test.ts
new file mode 100644
index 000000000..9ebad2ba8
--- /dev/null
+++ b/src/pkg/utils/network_rule_match.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it } from "vitest";
+import { cspRemovalAction, type NetworkRule, type NetworkRuleAction } from "@App/app/repo/network_rule";
+import type { NetworkRuleCondition } from "@App/app/repo/network_rule";
+import { matchRuleUrl, simulateNetworkRules } from "./network_rule_match";
+
+function rule(id: string, condition: NetworkRuleCondition, action: NetworkRuleAction, enabled = true): NetworkRule {
+ return { id, name: id, enabled, condition, action, createdAt: 1, updatedAt: 1 };
+}
+
+// 场景固定为主框架请求,条件都显式列出 resourceTypes;缺省语义单独由一条用例覆盖。
+const allSites: NetworkRuleCondition = { urlFilter: "*", resourceTypes: ["main_frame"] };
+const github: NetworkRuleCondition = { requestDomains: ["github.com"], resourceTypes: ["main_frame"] };
+
+function hits(simulation: ReturnType) {
+ return simulation.hits.map((hit) => [hit.position, hit.rule.id, hit.status, hit.causedBy]);
+}
+
+describe("simulateNetworkRules", () => {
+ it("命中规则按列表顺序返回,位次取自完整列表", () => {
+ const rules = [
+ rule("a", { requestDomains: ["example.com"], resourceTypes: ["main_frame"] }, { type: "block" }),
+ rule("b", github, cspRemovalAction()),
+ rule("c", allSites, { type: "modifyResponseHeaders", headers: [{ header: "x-a", operation: "remove" }] }),
+ ];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(hits(result)).toEqual([
+ [2, "b", "applied", undefined],
+ [3, "c", "applied", undefined],
+ ]);
+ expect(result.outcome).toBe("modified");
+ });
+
+ it("命中的 block 短路整个请求,顺序更靠前的改头规则也不执行", () => {
+ const rules = [
+ rule("csp", github, cspRemovalAction()),
+ rule("other", { requestDomains: ["example.com"], resourceTypes: ["main_frame"] }, { type: "block" }),
+ rule("block", allSites, { type: "block" }),
+ ];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("blocked");
+ expect(hits(result)).toEqual([
+ [1, "csp", "blocked", 3],
+ [3, "block", "applied", undefined],
+ ]);
+ });
+
+ it("顺序更靠前的 allow 压过靠后的 block,请求放行", () => {
+ const rules = [rule("allow", github, { type: "allow" }), rule("block", allSites, { type: "block" })];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("allowed");
+ expect(hits(result)).toEqual([
+ [1, "allow", "applied", undefined],
+ [2, "block", "overridden", 1],
+ ]);
+ });
+
+ it("靠前的 allow 让顺序更靠后的改头规则不执行,靠前的仍然执行", () => {
+ const headers = {
+ type: "modifyResponseHeaders" as const,
+ headers: [{ header: "x-a", operation: "remove" as const }],
+ };
+ const rules = [
+ rule("before", allSites, headers),
+ rule("allow", github, { type: "allow" }),
+ rule("after", allSites, headers),
+ ];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("allowed");
+ expect(hits(result)).toEqual([
+ [1, "before", "applied", undefined],
+ [2, "allow", "applied", undefined],
+ [3, "after", "allowed", 2],
+ ]);
+ });
+
+ it("多条 modifyHeaders 规则按列表顺序全部叠加", () => {
+ const rules = [
+ rule("ua", allSites, {
+ type: "modifyRequestHeaders",
+ headers: [{ header: "user-agent", operation: "set", value: "x" }],
+ }),
+ rule("csp", allSites, cspRemovalAction()),
+ ];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("modified");
+ expect(hits(result).every(([, , status]) => status === "applied")).toBe(true);
+ });
+
+ it("redirect 命中时给出目标地址", () => {
+ const rules = [rule("r", github, { type: "redirect", url: "https://example.com/" })];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("redirected");
+ expect(result.redirectUrl).toBe("https://example.com/");
+ });
+
+ it("请求被 redirect 之后,顺序更靠后的改头规则同样不执行", () => {
+ const headers = {
+ type: "modifyResponseHeaders" as const,
+ headers: [{ header: "x-a", operation: "remove" as const }],
+ };
+ const rules = [
+ rule("before", allSites, headers),
+ rule("redirect", github, { type: "redirect", url: "https://example.com/" }),
+ rule("after", allSites, headers),
+ ];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("redirected");
+ expect(hits(result)).toEqual([
+ [1, "before", "applied", undefined],
+ [2, "redirect", "applied", undefined],
+ [3, "after", "overridden", 2],
+ ]);
+ });
+
+ // 编译器(network_rule_compiler.ts)在未显式指定时会把受控集合列全,含 main_frame,
+ // 模拟必须跟着编译结果走,而不是跟着 DNR 自身「默认排除主文档」的语义走。
+ it("未指定 resourceTypes 的规则与编译结果一致,主文档同样命中", () => {
+ const rules = [rule("b", { requestDomains: ["github.com"] }, { type: "block" })];
+ expect(simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" }).hits).toHaveLength(
+ 1
+ );
+ expect(simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "script" }).hits).toHaveLength(1);
+ });
+
+ it("指定了 resourceTypes 时只匹配列出的类型", () => {
+ const rules = [rule("b", { ...github, resourceTypes: ["main_frame", "sub_frame"] }, { type: "block" })];
+ expect(simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" }).hits).toHaveLength(
+ 1
+ );
+ expect(simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "image" }).hits).toHaveLength(0);
+ });
+
+ it("停用的规则不参与模拟", () => {
+ const rules = [rule("b", allSites, { type: "block" }, false)];
+ const result = simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" });
+ expect(result.outcome).toBe("none");
+ expect(result.hits).toHaveLength(0);
+ });
+
+ it("无法解析为 http(s) 的地址标记为无效", () => {
+ const rules = [rule("b", allSites, { type: "block" })];
+ expect(simulateNetworkRules(rules, { url: "not a url", resourceType: "main_frame" }).valid).toBe(false);
+ expect(simulateNetworkRules(rules, { url: "ftp://x.com/a", resourceType: "main_frame" }).valid).toBe(false);
+ expect(simulateNetworkRules(rules, { url: "https://x.com/a", resourceType: "main_frame" }).valid).toBe(true);
+ });
+
+ it("urlFilter 按 DNR 语义匹配:通配符、域名锚点与结尾锚点", () => {
+ const request = (urlFilter: string, url: string) =>
+ simulateNetworkRules([rule("b", { urlFilter, resourceTypes: ["main_frame"] }, { type: "block" })], {
+ url,
+ resourceType: "main_frame",
+ }).hits.length;
+ expect(request("/ads/*.js", "https://a.com/ads/x.js")).toBe(1);
+ expect(request("/ads/*.js", "https://a.com/x.js")).toBe(0);
+ expect(request("||example.com/", "https://cdn.example.com/a")).toBe(1);
+ expect(request("||example.com/", "https://notexample.com/a")).toBe(0);
+ expect(request("/track|", "https://a.com/track")).toBe(1);
+ expect(request("/track|", "https://a.com/track/x")).toBe(0);
+ });
+
+ it("请求方法未指定时按 GET 模拟", () => {
+ const rules = [
+ rule("b", { ...github, requestMethods: ["post"], resourceTypes: ["main_frame"] }, { type: "block" }),
+ ];
+ expect(simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame" }).hits).toHaveLength(
+ 0
+ );
+ expect(
+ simulateNetworkRules(rules, { url: "https://github.com/a", resourceType: "main_frame", method: "post" }).hits
+ ).toHaveLength(1);
+ });
+});
+
+describe("matchRuleUrl", () => {
+ it("域名条件包含子域名并排除 excludedRequestDomains", () => {
+ expect(matchRuleUrl(github, "https://gist.github.com/a")).toBe("match");
+ expect(matchRuleUrl(github, "https://notgithub.com/a")).toBe("no-match");
+ expect(matchRuleUrl({ requestDomains: ["a.com"], excludedRequestDomains: ["x.a.com"] }, "https://x.a.com/")).toBe(
+ "no-match"
+ );
+ });
+
+ it("「所有网站」匹配任意 http(s) 地址,非 http(s) 判为无效", () => {
+ expect(matchRuleUrl(allSites, "https://anything.example/a")).toBe("match");
+ expect(matchRuleUrl(allSites, "chrome://settings")).toBe("invalid");
+ });
+
+ it("与模拟器共用匹配核心:urlFilter 通配符不退化为子串比较", () => {
+ expect(matchRuleUrl({ urlFilter: "/ads/*.js" }, "https://a.com/ads/x.js")).toBe("match");
+ expect(matchRuleUrl({ urlFilter: "/ads/*.js" }, "https://a.com/adsjs")).toBe("no-match");
+ });
+
+ it("按主文档 GET 判定:未指定 resourceTypes 时与编译结果一致地命中", () => {
+ expect(matchRuleUrl({ requestDomains: ["github.com"] }, "https://github.com/a")).toBe("match");
+ expect(
+ matchRuleUrl({ requestDomains: ["github.com"], resourceTypes: ["main_frame"] }, "https://github.com/a")
+ ).toBe("match");
+ });
+
+ it("高级区限定的资源类型或请求方法排除这次请求时报 scope-only,而不是谎报匹配", () => {
+ expect(matchRuleUrl({ requestDomains: ["github.com"], resourceTypes: ["image"] }, "https://github.com/a")).toBe(
+ "scope-only"
+ );
+ expect(matchRuleUrl({ requestDomains: ["github.com"], requestMethods: ["post"] }, "https://github.com/a")).toBe(
+ "scope-only"
+ );
+ // 范围之外仍然是 no-match:资源类型不该把「域名都不对」说成「只是类型不对」。
+ expect(matchRuleUrl({ requestDomains: ["github.com"], resourceTypes: ["image"] }, "https://other.com/a")).toBe(
+ "no-match"
+ );
+ });
+});
diff --git a/src/pkg/utils/network_rule_match.ts b/src/pkg/utils/network_rule_match.ts
new file mode 100644
index 000000000..6c694b4b1
--- /dev/null
+++ b/src/pkg/utils/network_rule_match.ts
@@ -0,0 +1,170 @@
+import type { NetworkRule, NetworkRuleActionType, NetworkRuleCondition } from "@App/app/repo/network_rule";
+import { NETWORK_RULE_RESOURCE_TYPES } from "./network_rule_condition";
+import type { NetworkRuleRequestMethod, NetworkRuleResourceType } from "./network_rule_condition";
+
+/**
+ * 规则条件的前端匹配与整套规则的裁决模拟。编辑抽屉的「试一试」与「测试匹配」共用这里的匹配核心,
+ * 避免两处对同一条规则给出不同答案。
+ *
+ * 模拟的输入只有 URL、资源类型与请求方法,因此 initiatorDomains / excludedInitiatorDomains
+ * 无从判定——当前编辑界面也产生不了这两个字段。
+ */
+
+/** `scope-only`:网址落在规则范围内,但这次请求被资源类型或请求方法排除。 */
+export type RuleUrlMatch = "match" | "scope-only" | "no-match" | "invalid";
+
+export type SimulatedRequest = {
+ url: string;
+ resourceType: NetworkRuleResourceType;
+ /** 未指定时按 GET 模拟,界面只让用户选资源类型。 */
+ method?: NetworkRuleRequestMethod;
+};
+
+/**
+ * `applied` 会执行;其余三种都不会执行:
+ * `blocked` 被某条 block 短路,`overridden` 被顺序更靠前的竞争规则决定了结果,
+ * `allowed` 被顺序更靠前的 allow 放行而跳过。
+ */
+export type NetworkRuleHitStatus = "applied" | "blocked" | "overridden" | "allowed";
+
+export type NetworkRuleHit = {
+ rule: NetworkRule;
+ /** 规则在完整列表中的位次(从 1 起),与列表页展示的顺序一致。 */
+ position: number;
+ status: NetworkRuleHitStatus;
+ /** 使该规则不执行的那条规则的位次。 */
+ causedBy?: number;
+};
+
+export type NetworkRuleOutcome = "none" | "blocked" | "redirected" | "allowed" | "modified";
+
+export type NetworkRuleSimulation = {
+ /** URL 不是可解析的 http(s) 地址时为 false,此时没有任何结果可给。 */
+ valid: boolean;
+ outcome: NetworkRuleOutcome;
+ redirectUrl?: string;
+ hits: NetworkRuleHit[];
+};
+
+/** block / redirect / allow 互相竞争,靠前者胜出;其余动作是叠加型的改头规则。 */
+const COMPETING_ACTIONS: NetworkRuleActionType[] = ["block", "redirect", "allow"];
+
+function isCompeting(type: NetworkRuleActionType): boolean {
+ return COMPETING_ACTIONS.includes(type);
+}
+
+function parseHttpUrl(input: string): URL | undefined {
+ let url: URL;
+ try {
+ url = new URL(input.trim());
+ } catch {
+ return undefined;
+ }
+ return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
+}
+
+function hostMatchesDomain(hostname: string, domain: string): boolean {
+ return hostname === domain || hostname.endsWith(`.${domain}`);
+}
+
+const REGEXP_SPECIALS = /[.+?^${}()|[\]\\]/g;
+
+/**
+ * 把 DNR 的 urlFilter 语法翻译成正则:`*` 通配、`^` 分隔符、`|` 首尾锚点、`||` 域名锚点,
+ * 其余情况是子串匹配。DNR 默认大小写不敏感(isUrlFilterCaseSensitive 缺省为 false),这里同样。
+ */
+function urlFilterToRegExp(filter: string): RegExp {
+ let pattern = filter;
+ let prefix = "";
+ let suffix = "";
+ if (pattern.startsWith("||")) {
+ prefix = "^https?://([^/]*\\.)?";
+ pattern = pattern.slice(2);
+ } else if (pattern.startsWith("|")) {
+ prefix = "^";
+ pattern = pattern.slice(1);
+ }
+ if (pattern.endsWith("|")) {
+ suffix = "$";
+ pattern = pattern.slice(0, -1);
+ }
+ const body = pattern
+ .split("*")
+ .map((part) => part.replace(REGEXP_SPECIALS, "\\$&").replaceAll("^", "(?:[^a-zA-Z0-9_\\-.%]|$)"))
+ .join(".*");
+ return new RegExp(`${prefix}${body}${suffix}`, "i");
+}
+
+function matchesUrl(condition: NetworkRuleCondition, url: URL): boolean {
+ const hostname = url.hostname.toLowerCase();
+ if (condition.excludedRequestDomains?.some((domain) => hostMatchesDomain(hostname, domain))) return false;
+ if (condition.requestDomains && !condition.requestDomains.some((domain) => hostMatchesDomain(hostname, domain))) {
+ return false;
+ }
+ if (condition.urlFilter !== undefined && !urlFilterToRegExp(condition.urlFilter).test(url.href)) return false;
+ return true;
+}
+
+function matchesRequest(condition: NetworkRuleCondition, url: URL, request: Required): boolean {
+ if (!matchesUrl(condition, url)) return false;
+ // DNR 自身的默认是「除 main_frame 外的全部资源类型」,但编译器在未显式指定时会把受控集合列全
+ // (network_rule_compiler.ts 的 compileCondition),模拟必须复述编译结果而不是 DNR 的默认。
+ const resourceTypes = condition.resourceTypes ?? NETWORK_RULE_RESOURCE_TYPES;
+ if (!resourceTypes.includes(request.resourceType)) return false;
+ if (condition.requestMethods && !condition.requestMethods.includes(request.method)) return false;
+ return true;
+}
+
+/**
+ * 保存前自查:把输入当作一次主文档 GET 导航,与模拟器共用 matchesRequest 这一个谓词。
+ * 若只掺入 URL 范围,用户在高级区显式选了资源类型后就会被告知「匹配」,
+ * 而编译出的条件根本不会作用于这次请求——所以这一档单独报 scope-only。
+ */
+export function matchRuleUrl(condition: NetworkRuleCondition, input: string): RuleUrlMatch {
+ const url = parseHttpUrl(input);
+ if (!url) return "invalid";
+ if (!matchesUrl(condition, url)) return "no-match";
+ const request: Required = { url: input, resourceType: "main_frame", method: "get" };
+ return matchesRequest(condition, url, request) ? "match" : "scope-only";
+}
+
+/**
+ * 按列表顺序(即优先级自高到低)模拟整套规则对一次请求的裁决。
+ * `rules` 是完整的有序列表,停用的规则会被跳过但仍占位次,于是结果里的 `#N` 与列表页一致。
+ */
+export function simulateNetworkRules(rules: NetworkRule[], request: SimulatedRequest): NetworkRuleSimulation {
+ const url = parseHttpUrl(request.url);
+ if (!url) return { valid: false, outcome: "none", hits: [] };
+ const target: Required = {
+ url: request.url,
+ resourceType: request.resourceType,
+ method: request.method ?? "get",
+ };
+
+ const matched = rules.flatMap((rule, index) =>
+ rule.enabled && matchesRequest(rule.condition, url, target) ? [{ rule, position: index + 1 }] : []
+ );
+ const winner = matched.find((hit) => isCompeting(hit.rule.action.type));
+ const winnerAction = winner?.rule.action.type;
+
+ const hits: NetworkRuleHit[] = matched.map(({ rule, position }) => {
+ if (!winner || position === winner.position) return { rule, position, status: "applied" };
+ // block 短路整个请求,连位次更靠前的改头规则都不会执行。
+ if (winnerAction === "block") return { rule, position, status: "blocked", causedBy: winner.position };
+ if (isCompeting(rule.action.type)) return { rule, position, status: "overridden", causedBy: winner.position };
+ // allow 与 redirect 都只跳过优先级低于自己的改头规则,靠前的照常叠加。
+ if (position > winner.position) {
+ const status = winnerAction === "allow" ? "allowed" : "overridden";
+ return { rule, position, status, causedBy: winner.position };
+ }
+ return { rule, position, status: "applied" };
+ });
+
+ if (winner?.rule.action.type === "block") return { valid: true, outcome: "blocked", hits };
+ if (winner?.rule.action.type === "redirect") {
+ return { valid: true, outcome: "redirected", redirectUrl: winner.rule.action.url, hits };
+ }
+ if (winner?.rule.action.type === "allow") return { valid: true, outcome: "allowed", hits };
+ const outcome = hits.some((hit) => hit.status === "applied") ? "modified" : "none";
+ return { valid: true, outcome, hits };
+}