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 ( + + + + {t("tools:network_rules_test_match")} + +

{t("tools:network_rules_match_description")}

+ + {paused && ( +
+
+ )} + +
+
+ + setUrl(event.target.value)} + /> +
+
+ + +
+
+ + {simulation && !simulation.valid && ( +

+ {t("tools:network_rules_try_invalid")} +

+ )} + + {simulation?.valid && ( + <> +
+
+ + {t("tools:network_rules_match_outcome_label")} + + + {outcomeLabels[simulation.outcome]} + +
+

{outcomeDetail()}

+
+ + {simulation.hits.length > 0 && ( + <> +

{t("tools:network_rules_match_hits")}

+
    + {simulation.hits.map((hit) => ( +
  • + {`#${hit.position}`} +
    +

    {hit.rule.name}

    +

    + {scopeText(hit.rule, t("tools:network_rules_all_websites"))} +

    +

    + +

    +
    + + {actionLabels[hit.rule.action.type]} + +
  • + ))} +
+ + )} + + )} + +

+

+ + + + +
+
+ ); +} 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 ( +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+
+ + {templateLabel} + + +
+ +
+ +