From ae9ba0e0641ec4409f3d3306e159275f9a533168 Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:41:38 +0200 Subject: [PATCH 01/11] test(resolver): use explicit fixture options --- docs/code-style.md | 5 - .../prepared-runtime-decisions.target.test.ts | 170 +- src/background/rules/resolver.target.test.ts | 1564 +++++++++-------- 3 files changed, 924 insertions(+), 815 deletions(-) diff --git a/docs/code-style.md b/docs/code-style.md index 58d9f67..947763d 100644 --- a/docs/code-style.md +++ b/docs/code-style.md @@ -186,11 +186,6 @@ Tracked in Notion — [Dług czytelności kodu — god-moduły i kontrakty funkc which carries the remediation order, risk per item and the rejected alternatives. Not fixed by this guide: -- `src/background/rules/resolver.target.test.ts` — the assertions still reach the - resolver through positional adapters, and one of them pins - `browserFingerprintSpoofingEnabled` to `false` where production defaults to - `true`. Kept deliberately so the options-object refactor could be verified - against untouched assertions; tracked as its own ticket. - `SettingsContext` keeps one provider and one public `useSettings()`, while its implementation is composed from domain state hooks and handler factories in `src/ui/options/state/use-settings-*.ts`. Preserve the composition order: diff --git a/src/background/prepared-runtime-decisions.target.test.ts b/src/background/prepared-runtime-decisions.target.test.ts index e453303..020cf2c 100644 --- a/src/background/prepared-runtime-decisions.target.test.ts +++ b/src/background/prepared-runtime-decisions.target.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createPreparedDecisions } from "@/background/prepared-runtime-decisions"; import { resolveProfileSnapshot } from "@/background/rules/resolver"; +import type { ProfileSnapshotOptions } from "@/background/rules/resolver-options"; import type { ContainerAssignment, ControlState, @@ -91,46 +92,51 @@ const buildPrepared = ({ containerAssignments, }); -const resolveBaseline = ( - hostname: string, - cookieStoreId: string | undefined, - rules: DomainRule[], - globalFallbackRule?: GlobalFallbackRule, - containerAssignments: ContainerAssignment[] = [], - trustedSites: TrustedSite[] = [], - domainFencingEnabled = false, -) => - resolveProfileSnapshot({ - browserFingerprintSource: { - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - platform: "MacIntel", - vendor: "Google Inc.", - hardwareConcurrency: 8, - deviceMemory: 8, - userAgentData: { - brands: [{ brand: "Chromium", version: "125" }], - fullVersionList: [{ brand: "Chromium", version: "125.0.6422.0" }], - mobile: false, - platform: "macOS", - }, +const baselineOptions = ({ + hostname, + cookieStoreId, + rules, + globalFallbackRule, + containerAssignments, + trustedSites, + domainFencingEnabled, +}: { + hostname: string; + cookieStoreId: string | undefined; + rules: DomainRule[]; + globalFallbackRule: GlobalFallbackRule | undefined; + containerAssignments: ContainerAssignment[]; + trustedSites: TrustedSite[]; + domainFencingEnabled: boolean; +}): ProfileSnapshotOptions => ({ + browserFingerprintSource: { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + platform: "MacIntel", + vendor: "Google Inc.", + hardwareConcurrency: 8, + deviceMemory: 8, + userAgentData: { + brands: [{ brand: "Chromium", version: "125" }], + fullVersionList: [{ brand: "Chromium", version: "125.0.6422.0" }], + mobile: false, + platform: "macOS", }, - fingerprintEnabled: true, - containerAssignments, - cookieStoreId, - debugMode: false, - domainFencingEnabled, - globalFallbackRule, - hostname, - profiles, - rules, - sharedSpoofing: undefined, - // Must match the prepared-inputs fixture above; this suite asserts that the - // baseline resolver and the prepared fast path agree. - sharedWorkerHandlingMode: "native", - trustedSites, - watchPositionDelay: [60, 500], - }); + }, + fingerprintEnabled: true, + containerAssignments, + cookieStoreId, + debugMode: false, + domainFencingEnabled, + globalFallbackRule, + hostname, + profiles, + rules, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites, + watchPositionDelay: [60, 500], +}); describe("createPreparedDecisions", () => { afterEach(() => { @@ -151,7 +157,17 @@ describe("createPreparedDecisions", () => { const prepared = buildPrepared({ rules }); const decision = prepared.resolveDecision("shop.example.com"); - const baseline = resolveBaseline("shop.example.com", undefined, rules); + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: undefined, + rules: rules, + globalFallbackRule: undefined, + containerAssignments: [], + trustedSites: [], + domainFencingEnabled: false, + }), + ); expect(comparableSnapshot(decision.snapshot)).toEqual(comparableSnapshot(baseline)); expect(decision.trustedSiteMatched).toBe(false); @@ -231,7 +247,17 @@ describe("createPreparedDecisions", () => { const prepared = buildPrepared({ rules, globalFallbackRule: fallback }); const decision = prepared.resolveDecision("shop.example.com"); - const baseline = resolveBaseline("shop.example.com", undefined, rules, fallback); + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: undefined, + rules: rules, + globalFallbackRule: fallback, + containerAssignments: [], + trustedSites: [], + domainFencingEnabled: false, + }), + ); expect(comparableSnapshot(decision.snapshot)).toEqual(comparableSnapshot(baseline)); expect(decision.snapshot?.authKey).toBeUndefined(); @@ -264,12 +290,16 @@ describe("createPreparedDecisions", () => { "shop.example.com", "firefox-container-1", ); - const baseline = resolveBaseline( - "shop.example.com", - "firefox-container-1", - rules, - undefined, - assignments, + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: "firefox-container-1", + rules: rules, + globalFallbackRule: undefined, + containerAssignments: assignments, + trustedSites: [], + domainFencingEnabled: false, + }), ); expect(comparableSnapshot(decision.snapshot)).toEqual(comparableSnapshot(baseline)); @@ -303,12 +333,16 @@ describe("createPreparedDecisions", () => { "shop.example.com", "firefox-container-1", ); - const baseline = resolveBaseline( - "shop.example.com", - "firefox-container-1", - [], - fallback, - assignments, + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: "firefox-container-1", + rules: [], + globalFallbackRule: fallback, + containerAssignments: assignments, + trustedSites: [], + domainFencingEnabled: false, + }), ); expect(comparableSnapshot(decision.snapshot)).toEqual(comparableSnapshot(baseline)); @@ -354,14 +388,16 @@ describe("createPreparedDecisions", () => { }); const first = prepared.resolveDecision("shop.example.com"); const second = prepared.resolveDecision("news.other.org"); - const baseline = resolveBaseline( - "shop.example.com", - undefined, - [], - fallback, - [], - [], - true, + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: undefined, + rules: [], + globalFallbackRule: fallback, + containerAssignments: [], + trustedSites: [], + domainFencingEnabled: true, + }), ); expect(first.fencesIdentity).toBe(true); @@ -565,7 +601,17 @@ describe("createPreparedDecisions", () => { ]; const prepared = buildPrepared({ rules, domainFencing: true }); const decision = prepared.resolveDecision("shop.example.com"); - const baseline = resolveBaseline("shop.example.com", undefined, rules); + const baseline = resolveProfileSnapshot( + baselineOptions({ + hostname: "shop.example.com", + cookieStoreId: undefined, + rules: rules, + globalFallbackRule: undefined, + containerAssignments: [], + trustedSites: [], + domainFencingEnabled: false, + }), + ); expect(decision.fencesIdentity).toBeFalsy(); expect(comparableSnapshot(decision.snapshot)).toEqual(comparableSnapshot(baseline)); diff --git a/src/background/rules/resolver.target.test.ts b/src/background/rules/resolver.target.test.ts index f907a0f..6debe25 100644 --- a/src/background/rules/resolver.target.test.ts +++ b/src/background/rules/resolver.target.test.ts @@ -3,15 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { matchTrustedSite, resolveActiveIdentity as resolveActiveIdentityBase, - resolveProfileSnapshot as resolveProfileSnapshotBase, - toRuleRuntimeSnapshot as toRuleRuntimeSnapshotBase, - toRuntimeSnapshot as toRuntimeSnapshotBase, + resolveProfileSnapshot, + toRuleRuntimeSnapshot, + toRuntimeSnapshot, } from "@/background/rules/resolver"; -import type { - ProfileSnapshotOptions, - RuleSnapshotOptions, - ToRuntimeSnapshotOptions, -} from "@/background/rules/resolver-options"; import { getTimeZoneOffsetMinutes } from "@/shared/time-zone-offset"; import type { ContainerAssignment, @@ -95,16 +90,6 @@ const withFallbackSeed = ( } : undefined; -const NATIVE_FP_SURFACES: SharedSpoofingConfig = { - canvas: false, - webGL: false, - audio: false, - navigator: false, - screen: false, - clientHints: false, - webRTC: false, -}; - const resolveActiveIdentity = ( hostname: string, cookieStoreId: string | undefined, @@ -118,102 +103,6 @@ const resolveActiveIdentity = ( withContainerSeeds(containerAssignments), ); -/** - * Positional adapters over the options-object SUTs, kept so the assertions in - * this file stay byte-identical across that signature change — they are the - * evidence that the refactor preserved behaviour, so they must not be co-edited - * with it. - * - * Converting these call sites (and retiring the pin noted below) is tracked - * separately; see the "Dług czytelności kodu" document in Notion. - */ -const toRuntimeSnapshot = ( - profile: ToRuntimeSnapshotOptions["profile"], - _retiredProfiles: readonly unknown[], - debugMode: boolean, - watchPositionDelay: [number, number], - fingerprintEnabled: boolean, - sharedSpoofing?: SharedSpoofingConfig, - ruleOverrides?: ToRuntimeSnapshotOptions["ruleOverrides"], - ruleSeedKey?: string, - browserFingerprintSource?: ToRuntimeSnapshotOptions["browserFingerprintSource"], - authKey?: string, - sharedWorkerHandlingMode: ToRuntimeSnapshotOptions["sharedWorkerHandlingMode"] = "native", -) => - toRuntimeSnapshotBase({ - authKey, - browserFingerprintSource, - fingerprintEnabled, - debugMode, - profile, - ruleOverrides, - ruleSeedKey, - sharedSpoofing, - sharedWorkerHandlingMode, - watchPositionDelay, - }); - -const toRuleRuntimeSnapshot = ( - rule: RuleSnapshotOptions["rule"], - profile: RuleSnapshotOptions["profile"], - _retiredProfiles: readonly unknown[], - debugMode: boolean, - watchPositionDelay: [number, number], - fingerprintEnabled: boolean, - sharedSpoofing?: SharedSpoofingConfig, - browserFingerprintSource?: RuleSnapshotOptions["browserFingerprintSource"], - sharedWorkerHandlingMode: RuleSnapshotOptions["sharedWorkerHandlingMode"] = "native", -) => - toRuleRuntimeSnapshotBase({ - browserFingerprintSource, - fingerprintEnabled, - debugMode, - profile, - rule, - sharedSpoofing, - sharedWorkerHandlingMode, - watchPositionDelay, - }); - -const resolveProfileSnapshot = ( - hostname: string, - cookieStoreId: string | undefined, - rules: readonly DomainRule[], - profiles: readonly Location[], - _retiredProfiles: readonly unknown[] = [], - containerAssignments: readonly ContainerAssignment[] = [], - debugMode = false, - watchPositionDelay: [number, number] = [60, 500], - // Preserve the historical location-only fixtures after the global switch - // became the real master gate. Master-off behavior uses the options-object - // SUT directly in its dedicated regression. - fingerprintEnabled = false, - sharedSpoofing?: SharedSpoofingConfig, - browserFingerprintSource?: ProfileSnapshotOptions["browserFingerprintSource"], - globalFallbackRule?: GlobalFallbackRule, - trustedSites: readonly TrustedSite[] = [], -) => - resolveProfileSnapshotBase({ - browserFingerprintSource, - fingerprintEnabled: true, - containerAssignments: withContainerSeeds(containerAssignments), - cookieStoreId, - debugMode, - domainFencingEnabled: false, - globalFallbackRule: withFallbackSeed(globalFallbackRule), - hostname, - profiles, - rules: withRuleSeeds(rules), - // The base function no longer defaults this; `native` reproduces the - // behaviour the assertions below were written against. - sharedWorkerHandlingMode: "native", - sharedSpoofing: fingerprintEnabled - ? sharedSpoofing - : { ...sharedSpoofing, ...NATIVE_FP_SURFACES }, - trustedSites, - watchPositionDelay, - }); - const getUtcOffsetMinutes = (timeZone: string, epochMs: number): number => { return getTimeZoneOffsetMinutes(timeZone, epochMs); }; @@ -253,13 +142,18 @@ describe("toRuntimeSnapshot date offset diagnostics", () => { const localOffsetMinutes = new Date(epochMs).getTimezoneOffset(); const comparisonTimeZone = pickComparisonTimeZone(epochMs); const targetOffsetMinutes = getUtcOffsetMinutes(comparisonTimeZone, epochMs); - const snapshot = toRuntimeSnapshot( - buildProfile(comparisonTimeZone), - [], - false, - [60, 500], - false, - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: false, + profile: buildProfile(comparisonTimeZone), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.date.timeZone).toBe(comparisonTimeZone); expect(snapshot.date.baseEpochMs).toBe(epochMs); @@ -275,26 +169,30 @@ describe("toRuntimeSnapshot date offset diagnostics", () => { describe("toRuntimeSnapshot SharedWorker compatibility mode", () => { it("defaults SharedWorkers to native compatibility and emits opt-out when disabled", () => { - const defaultSnapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - ); - const optOutSnapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - undefined, - undefined, - undefined, - "spoof", - ); + const defaultSnapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const optOutSnapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "spoof", + watchPositionDelay: [60, 500], + }); expect(defaultSnapshot.sharedWorkerHandlingMode).toBe("native"); expect(defaultSnapshot.sharedWorkerCompatibilityMode).toBeUndefined(); @@ -303,45 +201,42 @@ describe("toRuntimeSnapshot SharedWorker compatibility mode", () => { }); it("resolves SharedWorker handling with rule, shared spoofing, preference precedence", () => { - const preferenceSnapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - undefined, - undefined, - undefined, - "strict", - ); - const sharedSpoofingSnapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { sharedWorker: "spoof" }, - undefined, - undefined, - undefined, - undefined, - "strict", - ); - const ruleOverrideSnapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { sharedWorker: "spoof" }, - { sharedWorker: "native" }, - undefined, - undefined, - undefined, - "strict", - ); + const preferenceSnapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "strict", + watchPositionDelay: [60, 500], + }); + const sharedSpoofingSnapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: { sharedWorker: "spoof" }, + sharedWorkerHandlingMode: "strict", + watchPositionDelay: [60, 500], + }); + const ruleOverrideSnapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: { sharedWorker: "native" }, + ruleSeedKey: undefined, + sharedSpoofing: { sharedWorker: "spoof" }, + sharedWorkerHandlingMode: "strict", + watchPositionDelay: [60, 500], + }); expect(preferenceSnapshot.sharedWorkerHandlingMode).toBe("strict"); expect(sharedSpoofingSnapshot.sharedWorkerHandlingMode).toBe("strict"); @@ -359,13 +254,18 @@ describe("toRuntimeSnapshot New York offset handling", () => { vi.setSystemTime(new Date("2026-03-31T06:55:53.000Z")); const epochMs = Date.now(); - const snapshot = toRuntimeSnapshot( - buildProfile("America/New_York"), - [], - false, - [60, 500], - false, - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: false, + profile: buildProfile("America/New_York"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); const localOffsetMinutes = new Date(epochMs).getTimezoneOffset(); const targetOffsetMinutes = getUtcOffsetMinutes("America/New_York", epochMs); @@ -378,18 +278,23 @@ describe("toRuntimeSnapshot New York offset handling", () => { describe("toRuntimeSnapshot locale integration", () => { it("derives English-first runtime locale when the location prefers English content", () => { - const snapshot = toRuntimeSnapshot( - { + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: false, + profile: { ...buildProfile("Europe/Warsaw"), language: "pl", languages: ["pl", "en-US"], preferEnglishContent: true, }, - [], - false, - [60, 500], - false, - ); + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.locale).toMatchObject({ language: "en", @@ -426,13 +331,8 @@ describe("resolveProfileSnapshot container priority", () => { timeZone: "Europe/Berlin", }; - // Calls the base function directly: the positional adapter above pins - // `sharedWorkerHandlingMode` to "native" and `fingerprintEnabled` - // to false, so it cannot express this case. Neither member carries a - // per-parameter default any more, and nothing else asserts that they survive - // the trip through resolveProfileSnapshot into the snapshot. it("threads a strict SharedWorker mode and debugMode into the snapshot", () => { - const snapshot = resolveProfileSnapshotBase({ + const snapshot = resolveProfileSnapshot({ browserFingerprintSource: undefined, fingerprintEnabled: true, containerAssignments: [], @@ -458,52 +358,79 @@ describe("resolveProfileSnapshot container priority", () => { }); it("prefers a matching rule over a container assignment", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [{ pattern: "shop.example.com", locationId: "warsaw", enabled: true }], - [locationWarsaw, locationBerlin], - [], - [{ cookieStoreId: "firefox-container-1", locationId: "berlin" }], - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { cookieStoreId: "firefox-container-1", locationId: "berlin" }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationWarsaw, locationBerlin], + rules: withRuleSeeds([ + { pattern: "shop.example.com", locationId: "warsaw", enabled: true }, + ]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationWarsaw.latitude); expect(snapshot?.locale.language).toBe(locationWarsaw.language); + expect(snapshot?.fingerprint?.spoofingToggles?.canvas).toBe(true); }); it("falls back to a container assignment when no domain rule matches", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw, locationBerlin], - [], - [{ cookieStoreId: "firefox-container-1", locationId: "berlin" }], - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { cookieStoreId: "firefox-container-1", locationId: "berlin" }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationWarsaw, locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationBerlin.latitude); expect(snapshot?.locale.language).toBe(locationBerlin.language); }); it("skips disabled container assignments so the default rule can win", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw, locationBerlin], - [], - [{ cookieStoreId: "firefox-container-1", enabled: false, locationId: "berlin" }], - false, - [60, 500], - false, - undefined, - undefined, - { + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { cookieStoreId: "firefox-container-1", enabled: false, locationId: "berlin" }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, locationId: "warsaw", ruleSeedKey: "glb123", - }, - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw, locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationWarsaw.latitude); expect(snapshot?.locale.language).toBe(locationWarsaw.language); @@ -524,29 +451,31 @@ describe("resolveProfileSnapshot container priority", () => { ), ).toBeNull(); - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw, locationBerlin], - [], - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", fingerprintSurfaceOverrides: { geolocation: false }, }, - ], - false, - [60, 500], - false, - undefined, - undefined, - { + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, locationId: "warsaw", ruleSeedKey: "glb123", - }, - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw, locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationWarsaw.latitude); expect(snapshot?.locale.language).toBe(locationWarsaw.language); @@ -554,43 +483,55 @@ describe("resolveProfileSnapshot container priority", () => { it("keeps a container's own fingerprint identity while inheriting the Default Rule location", () => { const resolveContainer = (ruleSeedKey: string, authKey?: string) => - resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw], - [], - [ + resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", ruleSeedKey, ...(authKey ? { authKey } : {}), }, - ], - false, - [60, 500], - true, - undefined, - undefined, - { enabled: true, locationId: "warsaw", ruleSeedKey: "glb123" }, - ); + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ + enabled: true, + locationId: "warsaw", + ruleSeedKey: "glb123", + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); const containerA = resolveContainer("ctra01", "autha001"); const containerB = resolveContainer("ctrb02"); - const fallbackOnly = resolveProfileSnapshot( - "shop.example.com", - undefined, - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - true, - undefined, - undefined, - { enabled: true, locationId: "warsaw", ruleSeedKey: "glb123" }, - ); + const fallbackOnly = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ + enabled: true, + locationId: "warsaw", + ruleSeedKey: "glb123", + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); // Location is inherited from the Default Rule. expect(containerA?.geo.latitude).toBe(locationWarsaw.latitude); @@ -609,101 +550,126 @@ describe("resolveProfileSnapshot container priority", () => { }); it("does not spoof an identity-only container when the Default Rule is off", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw], - [], - [{ cookieStoreId: "firefox-container-1", ruleSeedKey: "ctra01" }], - false, - [60, 500], - true, - undefined, - undefined, - { enabled: false, locationId: "warsaw", ruleSeedKey: "glb123" }, - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { cookieStoreId: "firefox-container-1", ruleSeedKey: "ctra01" }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ + enabled: false, + locationId: "warsaw", + ruleSeedKey: "glb123", + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot).toBeNull(); }); it("lets a matching rule without its own preset inherit location from the active container assignment", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { + cookieStoreId: "firefox-container-1", + locationId: "berlin", + ruleSeedKey: "ctr001", + }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationBerlin], + rules: withRuleSeeds([ { pattern: "shop.example.com", locationId: "", enabled: true, ruleSeedKey: "rul001", }, - ], - [locationBerlin], - [], - [ - { - cookieStoreId: "firefox-container-1", - locationId: "berlin", - ruleSeedKey: "ctr001", - }, - ], - ); + ]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationBerlin.latitude); expect(snapshot?.locale.language).toBe(locationBerlin.language); }); it("lets a matching rule without its own preset inherit location from the Default Rule", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ + { cookieStoreId: "firefox-container-1", enabled: true }, + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ + enabled: true, + locationId: "warsaw", + ruleSeedKey: "glb123", + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([ { pattern: "shop.example.com", locationId: "", enabled: true, ruleSeedKey: "rul001", }, - ], - [locationWarsaw], - [], - [{ cookieStoreId: "firefox-container-1", enabled: true }], - false, - [60, 500], - false, - undefined, - undefined, - { - enabled: true, - locationId: "warsaw", - ruleSeedKey: "glb123", - }, - ); + ]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationWarsaw.latitude); expect(snapshot?.locale.language).toBe(locationWarsaw.language); }); it("keeps the container active when its geolocation spoofing is disabled", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationBerlin], - [], - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", fingerprintSurfaceOverrides: { geolocation: false }, locationId: "berlin", ruleSeedKey: "ctr001", }, - ], - false, - [60, 500], - true, - ); + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot).toMatchObject({ geolocationEnabled: false, @@ -720,10 +686,17 @@ describe("resolveProfileSnapshot container priority", () => { }); it("keeps a domain rule active when its geolocation spoofing is disabled", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - undefined, - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([ { pattern: "shop.example.com", locationId: "warsaw", @@ -731,14 +704,12 @@ describe("resolveProfileSnapshot container priority", () => { fingerprintSurfaceOverrides: { geolocation: false }, ruleSeedKey: "rul001", }, - ], - [locationWarsaw], - [], - [], - false, - [60, 500], - true, - ); + ]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot).toMatchObject({ geolocationEnabled: false, @@ -755,24 +726,25 @@ describe("resolveProfileSnapshot container priority", () => { }); it("applies fingerprint surface overrides from the winning container assignment", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationBerlin], - [], - [ + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", fingerprintSurfaceOverrides: { canvas: false }, locationId: "berlin", ruleSeedKey: "ctr001", }, - ], - false, - [0, 1000], - true, - { + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: { canvas: true, webGL: true, audio: true, @@ -781,62 +753,65 @@ describe("resolveProfileSnapshot container priority", () => { clientHints: true, webRTC: true, }, - ); + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [0, 1000], + }); expect(snapshot?.fingerprint?.spoofingToggles?.canvas).toBe(false); expect(snapshot?.fingerprint?.spoofingToggles?.webGL).toBe(true); }); it("returns null when neither a rule nor a container assignment matches", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw], - [], - [], - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot).toBeNull(); }); it("falls back to the global fallback rule when no domain rule or container assignment matches", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - false, - undefined, - undefined, - { + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, locationId: "warsaw", ruleSeedKey: "glb123", - }, - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.geo.latitude).toBe(locationWarsaw.latitude); expect(snapshot?.locale.language).toBe(locationWarsaw.language); }); it("resolves fingerprint-only runtime from the global fallback rule without a preset", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - undefined, - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - true, - undefined, - { + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", platform: "Win32", @@ -844,13 +819,25 @@ describe("resolveProfileSnapshot container priority", () => { hardwareConcurrency: 8, deviceMemory: 16, }, - { + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, ruleSeedKey: "glb123", // Persisted at the storage boundary before reaching the resolver. authKey: "abcd1234", - }, - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.fingerprint).toBeDefined(); expect(snapshot?.geolocationEnabled).toBe(false); @@ -859,7 +846,7 @@ describe("resolveProfileSnapshot container priority", () => { }); it("returns null when every effective surface is native", () => { - const snapshot = resolveProfileSnapshotBase({ + const snapshot = resolveProfileSnapshot({ browserFingerprintSource: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", @@ -899,7 +886,7 @@ describe("resolveProfileSnapshot container priority", () => { }); it("treats the global protection switch as a master runtime gate", () => { - const snapshot = resolveProfileSnapshotBase({ + const snapshot = resolveProfileSnapshot({ browserFingerprintSource: undefined, fingerprintEnabled: false, containerAssignments: [], @@ -930,7 +917,7 @@ describe("resolveProfileSnapshot container priority", () => { it("enables Temporal only behind the flag and effective Time & Locale", () => { const build = (timeLocale: boolean) => - resolveProfileSnapshotBase({ + resolveProfileSnapshot({ browserFingerprintSource: undefined, fingerprintEnabled: true, temporalApiEnabled: true, @@ -959,18 +946,8 @@ describe("resolveProfileSnapshot container priority", () => { it("preserves the persisted fallback authKey verbatim and never mints one", () => { const resolve = (globalFallbackRule: GlobalFallbackRule | undefined) => - resolveProfileSnapshot( - "shop.example.com", - undefined, - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - true, - undefined, - { + resolveProfileSnapshot({ + browserFingerprintSource: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", platform: "Win32", @@ -978,8 +955,20 @@ describe("resolveProfileSnapshot container priority", () => { hardwareConcurrency: 8, deviceMemory: 16, }, - globalFallbackRule, - ); + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(globalFallbackRule), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); // A persisted authKey is carried through unchanged on every resolve. const withKey = { enabled: true, ruleSeedKey: "glb123", authKey: "abcd1234" }; @@ -992,45 +981,49 @@ describe("resolveProfileSnapshot container priority", () => { }); it("returns null for a matching trusted site before domain rules are considered", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - undefined, - [{ pattern: "shop.example.com", locationId: "warsaw", enabled: true }], - [locationWarsaw], - [], - [], - false, - [60, 500], - false, - undefined, - undefined, - undefined, - [trustedSiteFor("shop.example.com")], - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([ + { pattern: "shop.example.com", locationId: "warsaw", enabled: true }, + ]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [trustedSiteFor("shop.example.com")], + watchPositionDelay: [60, 500], + }); expect(snapshot).toBeNull(); }); it("returns null for a matching trusted site before the default rule is considered", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - undefined, - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - false, - undefined, - undefined, - { + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, locationId: "warsaw", ruleSeedKey: "glb123", - }, - [trustedSiteFor("shop.example.com")], - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [trustedSiteFor("shop.example.com")], + watchPositionDelay: [60, 500], + }); expect(snapshot).toBeNull(); }); @@ -1046,25 +1039,27 @@ describe("resolveProfileSnapshot container priority", () => { }); it("keeps the default rule active when its geolocation spoofing is disabled", () => { - const snapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationWarsaw], - [], - [], - false, - [60, 500], - true, - undefined, - undefined, - { + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed({ enabled: true, fingerprintSurfaceOverrides: { geolocation: false }, locationId: "warsaw", ruleSeedKey: "glb123", - }, - ); + }), + hostname: "shop.example.com", + profiles: [locationWarsaw], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot).toMatchObject({ geolocationEnabled: false, @@ -1084,41 +1079,51 @@ describe("resolveProfileSnapshot container priority", () => { }); it("derives simple-engine fingerprint seeds from container assignment ruleSeedKey when no rule wins", () => { - const containerSnapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationBerlin], - [], - [ + const containerSnapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", locationId: "berlin", ruleSeedKey: "ctr001", }, - ], - false, - [0, 1000], - true, - ); + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [0, 1000], + }); - const alternateSnapshot = resolveProfileSnapshot( - "shop.example.com", - "firefox-container-1", - [], - [locationBerlin], - [], - [ + const alternateSnapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([ { cookieStoreId: "firefox-container-1", locationId: "berlin", ruleSeedKey: "ctr002", }, - ], - false, - [0, 1000], - true, - ); + ]), + cookieStoreId: "firefox-container-1", + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "shop.example.com", + profiles: [locationBerlin], + rules: withRuleSeeds([]), + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [0, 1000], + }); expect(containerSnapshot?.fingerprint?.canvasNoiseSeed).toBeDefined(); expect(containerSnapshot?.fingerprint?.canvasNoiseSeed).not.toBe( @@ -1201,13 +1206,18 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }); it("omits fingerprint data by default", () => { - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - false, - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: false, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint).toBeUndefined(); }); @@ -1230,16 +1240,18 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }, }); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - "seed01", - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "seed01", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expectDeviceShape(snapshot.fingerprint); expect(snapshot.fingerprint?.platform).toBe("Linux x86_64"); @@ -1279,34 +1291,36 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }, }; - const first = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { + const first = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: browserFingerprintSource, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "seed01", + sharedSpoofing: { clientHints: true, clientHintsVersionRotation: true, }, - undefined, - "seed01", - browserFingerprintSource, - ); - const second = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const second = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: browserFingerprintSource, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "seed02", + sharedSpoofing: { clientHints: true, clientHintsVersionRotation: true, }, - undefined, - "seed02", - browserFingerprintSource, - ); + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(first.fingerprint?.userAgent).toContain("Chrome/147.0.0.0"); expect(second.fingerprint?.userAgent).toContain("Chrome/147.0.0.0"); @@ -1330,17 +1344,21 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }, }); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: undefined, + sharedSpoofing: { clientHints: true, clientHintsVersionRotation: false, }, - ); + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.userAgent).toContain("Chrome/139.0.7204.62"); expect(snapshot.fingerprint?.appVersion).toContain("Chrome/139.0.7204.62"); @@ -1370,19 +1388,9 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }, }); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { - clientHints: true, - clientHintsVersionRotation: false, - }, - undefined, - "000000", - { + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", platform: "Win32", @@ -1402,7 +1410,18 @@ describe("toRuntimeSnapshot browser fingerprint", () => { platform: "Windows", }, }, - ); + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "000000", + sharedSpoofing: { + clientHints: true, + clientHintsVersionRotation: false, + }, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.clientHints?.fullVersionList).toEqual([ { brand: "Google Chrome", version: "147.0.7727.101" }, @@ -1428,19 +1447,9 @@ describe("toRuntimeSnapshot browser fingerprint", () => { }, }); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - { - clientHints: true, - clientHintsVersionRotation: true, - }, - undefined, - "000000", - { + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", platform: "Win32", @@ -1460,7 +1469,18 @@ describe("toRuntimeSnapshot browser fingerprint", () => { platform: "Windows", }, }, - ); + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "000000", + sharedSpoofing: { + clientHints: true, + clientHintsVersionRotation: true, + }, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.userAgent).toContain("Chrome/147.0.0.0"); expect(snapshot.fingerprint?.clientHints?.fullVersionList).toEqual([ @@ -1524,18 +1544,22 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { webRTC: true, }; - const snapshot = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor()], - [location], - [], - [], - false, - [60, 500], - true, - experimentalConfig, - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor()]), + sharedSpoofing: experimentalConfig, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.fingerprint?.spoofingToggles?.canvas).toBe(false); expect(snapshot?.fingerprint?.spoofingToggles?.webGL).toBe(true); @@ -1558,18 +1582,22 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { webRTC: true, }; - const snapshot = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor({ canvas: true })], - [location], - [], - [], - false, - [60, 500], - true, - experimentalConfig, - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor({ canvas: true })]), + sharedSpoofing: experimentalConfig, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); // Global surface disable is a hard safety stop — rule override cannot restore it expect(snapshot?.fingerprint?.spoofingToggles?.canvas).toBe(false); @@ -1586,18 +1614,22 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { webRTC: true, }; - const snapshot = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor({ audio: false })], - [location], - [], - [], - false, - [60, 500], - true, - experimentalConfig, - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor({ audio: false })]), + sharedSpoofing: experimentalConfig, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.fingerprint?.spoofingToggles?.audio).toBe(false); expect(snapshot?.fingerprint?.spoofingToggles?.canvas).toBe(true); @@ -1618,18 +1650,22 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { webRTC: true, }; - const snapshot = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor({ navigator: false, clientHints: false })], - [location], - [], - [], - false, - [60, 500], - true, - experimentalConfig, - ); + const snapshot = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor({ navigator: false, clientHints: false })]), + sharedSpoofing: experimentalConfig, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(snapshot?.fingerprint?.spoofingToggles?.navigator).toBe(false); expect(snapshot?.fingerprint?.spoofingToggles?.clientHints).toBe(false); @@ -1638,37 +1674,45 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { it("resolves Battery independently at global and rule level", () => { stubNavigator(); - const globallyDisabled = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor({ battery: true })], - [location], - [], - [], - false, - [60, 500], - true, - { battery: false }, - ); - const ruleDisabled = resolveProfileSnapshot( - "example.com", - undefined, - [ruleFor({ battery: false })], - [location], - [], - [], - false, - [60, 500], - true, - { battery: true }, - ); + const globallyDisabled = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor({ battery: true })]), + sharedSpoofing: { battery: false }, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); + const ruleDisabled = resolveProfileSnapshot({ + browserFingerprintSource: undefined, + containerAssignments: withContainerSeeds([]), + cookieStoreId: undefined, + debugMode: false, + domainFencingEnabled: false, + fingerprintEnabled: true, + globalFallbackRule: withFallbackSeed(undefined), + hostname: "example.com", + profiles: [location], + rules: withRuleSeeds([ruleFor({ battery: false })]), + sharedSpoofing: { battery: true }, + sharedWorkerHandlingMode: "native", + trustedSites: [], + watchPositionDelay: [60, 500], + }); expect(globallyDisabled?.fingerprint?.spoofingToggles?.battery).toBe(false); expect(ruleDisabled?.fingerprint?.spoofingToggles?.battery).toBe(false); }); it("without global protection enabled, no runtime snapshot appears", () => { - const snapshot = resolveProfileSnapshotBase({ + const snapshot = resolveProfileSnapshot({ browserFingerprintSource: undefined, fingerprintEnabled: false, containerAssignments: [], @@ -1702,16 +1746,18 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { webRTC: true, }; - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - experimentalConfig, - { webGL: false }, - "abc123", - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: { webGL: false }, + ruleSeedKey: "abc123", + sharedSpoofing: experimentalConfig, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.spoofingToggles?.webGL).toBe(false); expect(snapshot.fingerprint?.spoofingToggles?.canvas).toBe(true); @@ -1721,16 +1767,18 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { it("toRuntimeSnapshot applies ruleOverrides even without explicit global config", () => { stubNavigator(); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - { webGL: false, clientHints: false }, - "abc123", - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: { webGL: false, clientHints: false }, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.spoofingToggles?.webGL).toBe(false); expect(snapshot.fingerprint?.spoofingToggles?.clientHints).toBe(false); @@ -1740,16 +1788,18 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { it("defaults every surface to enabled when no shared spoofing config exists", () => { stubNavigator(); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(snapshot.fingerprint?.spoofingToggles).toEqual({ canvas: true, @@ -1767,36 +1817,42 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { stubNavigator(); const profile = buildProfile("Europe/Warsaw"); - const first = toRuntimeSnapshot( - profile, - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); - const second = toRuntimeSnapshot( - profile, - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); - const rotated = toRuntimeSnapshot( - profile, - [], - false, - [60, 500], - true, - undefined, - undefined, - "def456", - ); + const first = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const second = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const rotated = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + ruleOverrides: undefined, + ruleSeedKey: "def456", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(first.fingerprint?.canvasNoiseSeed).toBe( second.fingerprint?.canvasNoiseSeed, @@ -1814,36 +1870,42 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { ...buildProfile("Europe/Warsaw"), id: "preloaded-seed-profile", }; - const first = toRuleRuntimeSnapshot( - { + const first = toRuleRuntimeSnapshot({ + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + rule: { ruleSeedKey: "abc123", }, - profile, - [], - false, - [60, 500], - true, - ); - const second = toRuleRuntimeSnapshot( - { + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const second = toRuleRuntimeSnapshot({ + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + rule: { ruleSeedKey: "abc123", }, - profile, - [], - false, - [60, 500], - true, - ); - const rotated = toRuleRuntimeSnapshot( - { + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const rotated = toRuleRuntimeSnapshot({ + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: profile, + rule: { ruleSeedKey: "def456", }, - profile, - [], - false, - [60, 500], - true, - ); + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(first.fingerprint?.canvasNoiseSeed).toBe( second.fingerprint?.canvasNoiseSeed, @@ -1857,26 +1919,30 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { it("shapes hardwareConcurrency and deviceMemory deterministically for simple engine", () => { stubNavigator(); - const first = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); - const second = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); + const first = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); + const second = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(first.fingerprint?.hardwareConcurrency).toBe( second.fingerprint?.hardwareConcurrency, @@ -1895,16 +1961,18 @@ describe("resolveProfileSnapshot hierarchical spoofing toggles", () => { deviceMemory: 16, }); - const snapshot = toRuntimeSnapshot( - buildProfile("Europe/Warsaw"), - [], - false, - [60, 500], - true, - undefined, - undefined, - "abc123", - ); + const snapshot = toRuntimeSnapshot({ + authKey: undefined, + browserFingerprintSource: undefined, + debugMode: false, + fingerprintEnabled: true, + profile: buildProfile("Europe/Warsaw"), + ruleOverrides: undefined, + ruleSeedKey: "abc123", + sharedSpoofing: undefined, + sharedWorkerHandlingMode: "native", + watchPositionDelay: [60, 500], + }); expect(typeof snapshot.fingerprint?.hardwareConcurrency).toBe("number"); expect(snapshot.fingerprint?.hardwareConcurrency).toBeGreaterThan(0); From 38e0ca08e6e6218a0d556bb46df7dad474d92f9a Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:05 +0200 Subject: [PATCH 02/11] feat(control-d): add experimental regional sync --- CHANGELOG.md | 7 + config/manifest.ts | 7 + config/vite.config.ts | 18 + src/background/index.ts | 11 +- src/background/location-drafts.ts | 3 + .../storage/locations.target.test.ts | 37 ++ src/background/storage/locations.ts | 45 +- .../control-d/background-entry.target.test.ts | 74 +++ .../control-d/background-entry.ts | 441 ++++++++++++++ src/experimental/control-d/client.test.ts | 280 +++++++++ src/experimental/control-d/client.ts | 459 ++++++++++++++ src/experimental/control-d/compiler.test.ts | 225 +++++++ src/experimental/control-d/compiler.ts | 278 +++++++++ src/experimental/control-d/contracts.ts | 189 ++++++ .../control-d/reconcile.target.test.ts | 401 ++++++++++++ src/experimental/control-d/reconcile.ts | 517 ++++++++++++++++ src/experimental/control-d/redaction.test.ts | 27 + src/experimental/control-d/redaction.ts | 25 + .../control-d/resource-names.test.ts | 36 ++ src/experimental/control-d/resource-names.ts | 43 ++ src/experimental/control-d/storage.test.ts | 84 +++ src/experimental/control-d/storage.ts | 95 +++ src/experimental/control-d/sync-queue.test.ts | 40 ++ src/experimental/control-d/sync-queue.ts | 26 + src/experimental/control-d/ui-entry.tsx | 576 ++++++++++++++++++ src/scripts/manifest-config.target.test.ts | 15 + src/shared/profile-schema.ts | 5 + src/shared/shared-model-types.ts | 3 + .../experimental-control-d-background.ts | 2 + src/stubs/experimental-control-d-ui.tsx | 7 + src/ui/i18n/en-sections/common.ts | 1 + .../modals/LocationDetailsFields.tsx | 34 ++ .../options/components/tabs/AdvancedTab.tsx | 18 +- .../options/state/use-settings-locations.ts | 1 + tests/build-contracts/chromium-build.test.ts | 10 + .../experimental-integrations.ts | 41 ++ tests/build-contracts/firefox-build.test.ts | 10 + .../e2e/extension-options-navigation.spec.ts | 52 ++ 38 files changed, 4136 insertions(+), 7 deletions(-) create mode 100644 src/experimental/control-d/background-entry.target.test.ts create mode 100644 src/experimental/control-d/background-entry.ts create mode 100644 src/experimental/control-d/client.test.ts create mode 100644 src/experimental/control-d/client.ts create mode 100644 src/experimental/control-d/compiler.test.ts create mode 100644 src/experimental/control-d/compiler.ts create mode 100644 src/experimental/control-d/contracts.ts create mode 100644 src/experimental/control-d/reconcile.target.test.ts create mode 100644 src/experimental/control-d/reconcile.ts create mode 100644 src/experimental/control-d/redaction.test.ts create mode 100644 src/experimental/control-d/redaction.ts create mode 100644 src/experimental/control-d/resource-names.test.ts create mode 100644 src/experimental/control-d/resource-names.ts create mode 100644 src/experimental/control-d/storage.test.ts create mode 100644 src/experimental/control-d/storage.ts create mode 100644 src/experimental/control-d/sync-queue.test.ts create mode 100644 src/experimental/control-d/sync-queue.ts create mode 100644 src/experimental/control-d/ui-entry.tsx create mode 100644 src/stubs/experimental-control-d-background.ts create mode 100644 src/stubs/experimental-control-d-ui.tsx create mode 100644 tests/build-contracts/experimental-integrations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2304c0f..9c3f3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on Keep a Changelog and the project follows Semantic Version ## [Unreleased] +### Added + +- Added an experimental, one-way Control D regional DNS integration to local + and beta builds. It previews managed rule changes, requires confirmation + before the first sync, preserves unrelated Control D resources, and guides + users through manual browser DoH setup. + ### Fixed - Confirm installed Battery protection before a page first queries the API, and clear diff --git a/config/manifest.ts b/config/manifest.ts index 91504e3..676ac90 100644 --- a/config/manifest.ts +++ b/config/manifest.ts @@ -9,10 +9,12 @@ const displayVersion = process.env.PT_DISPLAY_VERSION ?? ""; export const createManifest = ({ browserTarget = process.env.PT_BROWSER_TARGET, + buildChannel = process.env.PT_BUILD_CHANNEL ?? "local", version = manifestVersion, versionName = displayVersion, }: { browserTarget?: string | undefined; + buildChannel?: string | undefined; version?: string | undefined; versionName?: string | undefined; } = {}) => { @@ -69,6 +71,11 @@ export const createManifest = ({ } : {}), host_permissions: [""], + ...(buildChannel === "release" + ? {} + : { + optional_host_permissions: ["https://api.controld.com/*"], + }), background: { service_worker: "src/background/index.ts", type: "module", diff --git a/config/vite.config.ts b/config/vite.config.ts index 6260f0d..ab69628 100644 --- a/config/vite.config.ts +++ b/config/vite.config.ts @@ -497,6 +497,24 @@ export default defineConfig({ }, resolve: { alias: [ + { + find: "@/experimental/control-d/background-entry", + replacement: path.resolve( + repositoryRootDirectory, + buildChannel === "release" + ? "src/stubs/experimental-control-d-background.ts" + : "src/experimental/control-d/background-entry.ts", + ), + }, + { + find: "@/experimental/control-d/ui-entry", + replacement: path.resolve( + repositoryRootDirectory, + buildChannel === "release" + ? "src/stubs/experimental-control-d-ui.tsx" + : "src/experimental/control-d/ui-entry.tsx", + ), + }, ...(buildTarget === "chromium" ? [ { diff --git a/src/background/index.ts b/src/background/index.ts index 8d7a6f6..bdb89d1 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -43,7 +43,10 @@ import { resolvePopupNotification as resolvePopupNotificationStore, syncUpdateNotices, } from "@/background/storage/popup-notifications"; -import { getOnboardingCompleted } from "@/background/storage/preferences"; +import { + getOnboardingCompleted, + getPreferences, +} from "@/background/storage/preferences"; import { setTrustedSiteEnabled, upsertTrustedSite, @@ -66,6 +69,7 @@ import { recordSurfaceEvidence, } from "@/background/surface-evidence-tracker"; import { createXRayHandlers } from "@/background/xray-commands"; +import { registerControlD } from "@/experimental/control-d/background-entry"; import { fireAndForget } from "@/shared/async"; import { BRAND_DISPLAY_NAME } from "@/shared/brand"; import { BUILD_BROWSER_TARGET, BUILD_CHANNEL } from "@/shared/build-flags"; @@ -338,6 +342,11 @@ const { // messages can reach the background router. registerRewriteListeners(); +registerControlD({ + getDebugMode: async () => + runtimeState.getLastKnownDebugMode() ?? (await getPreferences()).debugMode, +}); + registerMessageRouter({ isSupportedWebUrl, getControlState, diff --git a/src/background/location-drafts.ts b/src/background/location-drafts.ts index 721250d..f33768d 100644 --- a/src/background/location-drafts.ts +++ b/src/background/location-drafts.ts @@ -258,6 +258,9 @@ export const buildDraftFromCandidate = ( label: candidate.label, latitude: candidate.latitude, longitude: candidate.longitude, + ...(candidate.address?.country_code + ? { countryCode: candidate.address.country_code.toUpperCase() } + : {}), accuracy: 25, noiseRadius: 50, language: selectedLanguageOption.language, diff --git a/src/background/storage/locations.target.test.ts b/src/background/storage/locations.target.test.ts index 0ca358e..646e4a0 100644 --- a/src/background/storage/locations.target.test.ts +++ b/src/background/storage/locations.target.test.ts @@ -77,6 +77,9 @@ describe("loadLocations", () => { "spf-berlin", "spf-madrid", ]); + expect( + EXAMPLE_LOCATIONS.every((profile) => profile.countryCode?.length === 2), + ).toBe(true); }); it("randomizes preset coordinates inside the requested radius without changing privacy radius", () => { @@ -152,6 +155,40 @@ describe("loadLocations", () => { ]); }); + it("restores country codes for older built-in regional presets", async () => { + const withoutCountryCode = EXAMPLE_LOCATIONS.map( + ({ countryCode: _countryCode, ...profile }) => profile, + ); + storageState[LOCATIONS_STORAGE_KEY] = [ + withoutCountryCode[0], + withoutCountryCode[1], + withoutCountryCode[3], + ]; + + const profiles = await loadLocations(); + + expect(profiles.map((profile) => profile.countryCode)).toEqual(["PL", "FR", "CA"]); + }); + + it("keeps older profiles valid when they have no country code", async () => { + storageState[LOCATIONS_STORAGE_KEY] = [ + { + id: "legacy-countryless", + label: "Legacy", + latitude: 1, + longitude: 2, + accuracy: 25, + noiseRadius: 50, + language: "en", + languages: ["en"], + timeZone: "UTC", + }, + ]; + + const [profile] = await loadLocations(); + expect(profile).not.toHaveProperty("countryCode"); + }); + it("does not read a retired namespace outside the startup migrator", async () => { const retiredProfilesKey = `${["geo", "warp"].join("")}.profiles`; storageState[retiredProfilesKey] = [ diff --git a/src/background/storage/locations.ts b/src/background/storage/locations.ts index 2b66b8b..26d3f5b 100644 --- a/src/background/storage/locations.ts +++ b/src/background/storage/locations.ts @@ -28,6 +28,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Warsaw", latitude: 52.2297, longitude: 21.0122, + countryCode: "PL", accuracy: 25, noiseRadius: 50, language: "pl", @@ -39,6 +40,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Paris", latitude: 48.8566, longitude: 2.3522, + countryCode: "FR", accuracy: 25, noiseRadius: 50, language: "fr-FR", @@ -50,6 +52,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "London", latitude: 51.5074, longitude: -0.1278, + countryCode: "GB", accuracy: 25, noiseRadius: 50, language: "en-GB", @@ -61,6 +64,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Ottawa", latitude: 45.4215, longitude: -75.6972, + countryCode: "CA", accuracy: 25, noiseRadius: 50, language: "en-CA", @@ -72,6 +76,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "New York", latitude: 40.7128, longitude: -74.006, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -83,6 +88,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Las Vegas", latitude: 36.1699, longitude: -115.1398, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -94,6 +100,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "San Francisco", latitude: 37.7749, longitude: -122.4194, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -105,6 +112,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Sydney", latitude: -33.8688, longitude: 151.2093, + countryCode: "AU", accuracy: 25, noiseRadius: 50, language: "en-AU", @@ -116,6 +124,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Beijing", latitude: 39.9042, longitude: 116.4074, + countryCode: "CN", accuracy: 25, noiseRadius: 50, language: "zh-CN", @@ -127,6 +136,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Hong Kong", latitude: 22.3193, longitude: 114.1694, + countryCode: "HK", accuracy: 25, noiseRadius: 50, language: "zh-HK", @@ -138,6 +148,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "New Delhi", latitude: 28.6139, longitude: 77.209, + countryCode: "IN", accuracy: 25, noiseRadius: 50, language: "hi", @@ -149,6 +160,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Cairo", latitude: 30.0444, longitude: 31.2357, + countryCode: "EG", accuracy: 25, noiseRadius: 50, language: "ar", @@ -160,6 +172,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Lagos", latitude: 6.5244, longitude: 3.3792, + countryCode: "NG", accuracy: 25, noiseRadius: 50, language: "en", @@ -171,6 +184,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Kyiv", latitude: 50.4501, longitude: 30.5234, + countryCode: "UA", accuracy: 25, noiseRadius: 50, language: "uk", @@ -182,6 +196,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Kinshasa", latitude: -4.4419, longitude: 15.2663, + countryCode: "CD", accuracy: 25, noiseRadius: 50, language: "fr", @@ -193,6 +208,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Sao Paulo", latitude: -23.5558, longitude: -46.6396, + countryCode: "BR", accuracy: 25, noiseRadius: 50, language: "pt-BR", @@ -204,6 +220,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Buenos Aires", latitude: -34.6037, longitude: -58.3816, + countryCode: "AR", accuracy: 25, noiseRadius: 50, language: "es", @@ -215,6 +232,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Lima", latitude: -12.0464, longitude: -77.0428, + countryCode: "PE", accuracy: 25, noiseRadius: 50, language: "es", @@ -226,6 +244,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Rio de Janeiro", latitude: -22.9068, longitude: -43.1729, + countryCode: "BR", accuracy: 25, noiseRadius: 50, language: "pt-BR", @@ -237,6 +256,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Caracas", latitude: 10.4806, longitude: -66.9036, + countryCode: "VE", accuracy: 25, noiseRadius: 50, language: "es", @@ -248,6 +268,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Berlin", latitude: 52.52, longitude: 13.405, + countryCode: "DE", accuracy: 25, noiseRadius: 50, language: "de-DE", @@ -259,6 +280,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Madrid", latitude: 40.4168, longitude: -3.7038, + countryCode: "ES", accuracy: 25, noiseRadius: 50, language: "es", @@ -309,19 +331,34 @@ export const DEFAULT_LOCATIONS: Location[] = FX_RUNTIME_TEST_HOST ? EXAMPLE_LOCATIONS.map((location) => ({ ...location })) : []; +const EXAMPLE_COUNTRY_CODES = new Map( + EXAMPLE_LOCATIONS.flatMap((location) => + location.countryCode ? [[location.id, location.countryCode] as const] : [], + ), +); + +const enrichKnownCountryCodes = (locations: readonly Location[]): Location[] => + locations.map((location) => { + if (location.countryCode) return location; + const countryCode = EXAMPLE_COUNTRY_CODES.get(location.id); + return countryCode ? { ...location, countryCode } : location; + }); + export const loadLocations = async (): Promise => { const stored = await chrome.storage.local.get(LOCATIONS_STORAGE_KEY); const locations = stored[LOCATIONS_STORAGE_KEY]; - return Array.isArray(locations) - ? normalizeLocations(stripLegacyRefs(locations) as Location[]) - : normalizeLocations(DEFAULT_LOCATIONS); + return enrichKnownCountryCodes( + Array.isArray(locations) + ? normalizeLocations(stripLegacyRefs(locations) as Location[]) + : normalizeLocations(DEFAULT_LOCATIONS), + ); }; export const saveLocations = async (locations: readonly Location[]): Promise => { const stored = await chrome.storage.local.get(LOCATIONS_STORAGE_KEY); await chrome.storage.local.set({ [LOCATIONS_STORAGE_KEY]: mergeLegacyRefs( - normalizeLocations(locations), + enrichKnownCountryCodes(normalizeLocations(locations)), stored[LOCATIONS_STORAGE_KEY], ), }); diff --git a/src/experimental/control-d/background-entry.target.test.ts b/src/experimental/control-d/background-entry.target.test.ts new file mode 100644 index 0000000..5fe70dd --- /dev/null +++ b/src/experimental/control-d/background-entry.target.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerControlD } from "./background-entry"; +import { CONTROL_D_COMMANDS } from "./contracts"; + +import { logExtensionEvent } from "@/background/logger"; + +vi.mock("@/background/logger", () => ({ + logExtensionEvent: vi.fn(), +})); + +type MessageListener = ( + message: unknown, + sender: { id?: string }, + sendResponse: (response: unknown) => void, +) => boolean; + +const storageState: Record = {}; +let messageListener: MessageListener; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(storageState)) + Reflect.deleteProperty(storageState, key); + + vi.stubGlobal("chrome", { + runtime: { + id: "extension-id", + onMessage: { + addListener: vi.fn((listener: MessageListener) => { + messageListener = listener; + }), + }, + }, + storage: { + local: { + get: vi.fn(async (key: string) => + key in storageState ? { [key]: storageState[key] } : {}, + ), + set: vi.fn(async (values: Record) => { + Object.assign(storageState, values); + }), + remove: vi.fn(async (key: string) => Reflect.deleteProperty(storageState, key)), + }, + onChanged: { addListener: vi.fn() }, + }, + }); +}); + +describe("Control D background entry", () => { + it("persists a toggle action in extension logs when debug mode comes from storage", async () => { + registerControlD({ getDebugMode: async () => true }); + + const response = await new Promise((resolve) => { + expect( + messageListener( + { type: CONTROL_D_COMMANDS.setEnabled, enabled: true }, + { id: "extension-id" }, + resolve, + ), + ).toBe(true); + }); + + expect(response).toMatchObject({ ok: true, state: { enabled: true } }); + await vi.waitFor(() => { + expect(logExtensionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + event: "control-d.integration.toggled", + }), + ); + }); + }); +}); diff --git a/src/experimental/control-d/background-entry.ts b/src/experimental/control-d/background-entry.ts new file mode 100644 index 0000000..0565a23 --- /dev/null +++ b/src/experimental/control-d/background-entry.ts @@ -0,0 +1,441 @@ +/* eslint-disable max-params -- Logging keeps redaction context explicit. */ +import { ControlDApiError, ControlDClient } from "./client"; +import { + CONTROL_D_COMMANDS, + isControlDCommand, + type ControlDCommand, + type ControlDConfig, + type ControlDMapping, +} from "./contracts"; +import { + applyControlDSync, + ControlDConflictError, + isControlDAuthError, + prepareControlDSync, + type ControlDPreparedSync, +} from "./reconcile"; +import { redactControlDLogValue } from "./redaction"; +import { + forgetControlDApiKey, + loadControlDApiKey, + loadControlDConfig, + saveControlDApiKey, + saveControlDConfig, + toControlDPublicState, +} from "./storage"; +import { createControlDSyncQueue } from "./sync-queue"; + +import { logExtensionEvent } from "@/background/logger"; +import { LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; +import { RULES_STORAGE_KEY } from "@/background/storage/rules"; +import { fireAndForget } from "@/shared/async"; +import { ExtensionLogLevel, LogCategory } from "@/shared/types"; + +type BackgroundEntryDeps = { + getDebugMode: () => boolean | Promise; +}; + +type SyncResult = + | { + ok: true; + next: ControlDConfig; + prepared: ControlDPreparedSync; + } + | { ok: false; failed: ControlDConfig; error: unknown } + | null; + +const log = ( + deps: BackgroundEntryDeps, + event: string, + details: Record, + level = ExtensionLogLevel.Info, + apiKey?: string, +): void => { + fireAndForget( + Promise.resolve(deps.getDebugMode()).then((enabled) => { + logExtensionEvent({ + enabled, + category: LogCategory.System, + event, + level, + payload: { + details: redactControlDLogValue(details, apiKey) as Record, + }, + }); + }), + ); +}; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Control D integration failed."; + +const apiErrorDetails = (error: unknown): Record => { + if (!(error instanceof ControlDApiError)) { + return { + status: null, + requestId: null, + retryAfter: null, + cause: null, + operation: null, + apiCode: null, + }; + } + return { + status: error.status, + requestId: error.requestId, + retryAfter: error.retryAfterSeconds, + cause: error.causeMessage, + operation: error.operation, + apiCode: error.apiCode, + }; +}; + +const saveFailure = async ( + config: ControlDConfig, + error: unknown, +): Promise => { + const authError = isControlDAuthError(error); + const conflict = error instanceof ControlDConflictError; + let status: ControlDConfig["status"] = "error"; + if (authError) status = "auth-error"; + else if (conflict) status = "conflict"; + const failed: ControlDConfig = { + ...config, + status, + autoSyncEnabled: authError || conflict ? false : config.autoSyncEnabled, + lastAttemptAt: new Date().toISOString(), + lastError: errorMessage(error), + }; + await saveControlDConfig(failed); + return failed; +}; + +const isMapping = (value: unknown): value is ControlDMapping => { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.locationId === "string" && + (typeof candidate.proxyPk === "string" || candidate.proxyPk === null) && + ["exact", "approximate", "skipped"].includes(candidate.status ?? "") && + typeof candidate.confirmed === "boolean" + ); +}; + +// eslint-disable-next-line max-lines-per-function -- Owns one single-flight lifecycle. +const createController = (deps: BackgroundEntryDeps) => { + let debounceTimer: ReturnType | null = null; + const syncQueue = createControlDSyncQueue(); + let rerunRequested = false; + const createClient = (apiKey: string): ControlDClient => + new ControlDClient(apiKey, fetch, 12_000, (retry) => + log(deps, "control-d.api.retry", retry, undefined, apiKey), + ); + + const runSync = async ({ + confirmApproximate, + repair, + automatic, + }: { + confirmApproximate: boolean; + repair: boolean; + automatic: boolean; + }): Promise => { + let config = await loadControlDConfig(); + const apiKey = await loadControlDApiKey(); + if (!apiKey) throw new Error("Connect a Control D API key first."); + if ( + automatic && + (!config.enabled || !config.autoSyncEnabled || !config.lastSyncedHash) + ) + return null; + + config = { + ...config, + status: "syncing", + lastAttemptAt: new Date().toISOString(), + lastError: null, + }; + await saveControlDConfig(config); + log(deps, "control-d.sync.start", { automatic, repair }, undefined, apiKey); + + try { + const client = createClient(apiKey); + const prepared = await prepareControlDSync(client, config); + const next = await applyControlDSync({ + client, + config, + prepared, + confirmApproximate, + repair, + }); + await saveControlDConfig(next); + log( + deps, + "control-d.sync.success", + { + automatic, + addRules: prepared.diff.addRules, + updateRules: prepared.diff.updateRules, + deleteRules: prepared.diff.deleteRules, + unchangedRules: prepared.diff.unchangedRules, + }, + undefined, + apiKey, + ); + return { ok: true, next, prepared }; + } catch (error) { + const failed = await saveFailure(config, error); + log( + deps, + "control-d.sync.failure", + { + automatic, + error: errorMessage(error), + ...apiErrorDetails(error), + conflict: error instanceof ControlDConflictError, + }, + ExtensionLogLevel.Error, + apiKey, + ); + return { ok: false, failed, error }; + } + }; + + const runExclusive = async ({ + confirmApproximate, + repair, + automatic, + }: { + confirmApproximate: boolean; + repair: boolean; + automatic: boolean; + }): Promise => { + try { + return await syncQueue.run(() => + runSync({ confirmApproximate, repair, automatic }), + ); + } finally { + if (rerunRequested) { + rerunRequested = false; + scheduleAutomatic(); + } + } + }; + + const runAutomatic = async (): Promise => { + if (syncQueue.isBusy()) { + rerunRequested = true; + return; + } + await runExclusive({ + confirmApproximate: true, + repair: false, + automatic: true, + }); + }; + + const scheduleAutomatic = (): void => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + fireAndForget(runAutomatic()); + }, 1_500); + }; + + // eslint-disable-next-line max-lines-per-function, sonarjs/cognitive-complexity -- Command boundary keeps secrets in background. + const respond = async (command: ControlDCommand): Promise => { + if (command.type === CONTROL_D_COMMANDS.getState) { + return { + ok: true, + state: await toControlDPublicState(await loadControlDConfig()), + }; + } + + if (command.type === CONTROL_D_COMMANDS.setEnabled) { + const config = await loadControlDConfig(); + const next = { ...config, enabled: command.enabled }; + await saveControlDConfig(next); + log(deps, "control-d.integration.toggled", { enabled: command.enabled }); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.connect) { + const apiKey = command.apiKey.trim(); + if (!apiKey) return { ok: false, error: "Enter a Control D API key." }; + const config = await loadControlDConfig(); + try { + const client = createClient(apiKey); + const [profiles, proxies] = await Promise.all([ + client.listProfiles(), + client.listProxies(), + ]); + if (proxies.length === 0) throw new Error("No usable proxy locations found."); + await saveControlDApiKey(apiKey); + const next: ControlDConfig = { + ...config, + connected: true, + status: "ready", + lastAttemptAt: new Date().toISOString(), + lastError: null, + }; + await saveControlDConfig(next); + log( + deps, + "control-d.connection.success", + { profileCount: profiles.length, proxyCount: proxies.length }, + undefined, + apiKey, + ); + return { ok: true, state: await toControlDPublicState(next) }; + } catch (error) { + const failed = await saveFailure(config, error); + log( + deps, + "control-d.connection.failure", + { error: errorMessage(error) }, + ExtensionLogLevel.Error, + apiKey, + ); + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(failed), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.disconnect) { + const config = await loadControlDConfig(); + await forgetControlDApiKey(); + const next: ControlDConfig = { + ...config, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + lastError: null, + }; + await saveControlDConfig(next); + log(deps, "control-d.disconnected", { resourcesPreserved: true }); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.updateMapping) { + if (!isMapping(command.mapping)) { + return { ok: false, error: "Invalid Control D location mapping." }; + } + const config = await loadControlDConfig(); + const next: ControlDConfig = { + ...config, + locationMappings: { + ...config.locationMappings, + [command.mapping.locationId]: command.mapping, + }, + }; + await saveControlDConfig(next); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.dnsAction) { + log(deps, "control-d.dns.action", { + action: command.action, + outcome: command.outcome, + }); + return { + ok: true, + state: await toControlDPublicState(await loadControlDConfig()), + }; + } + + const config = await loadControlDConfig(); + const apiKey = await loadControlDApiKey(); + if (!apiKey) { + return { + ok: false, + error: "Connect a Control D API key first.", + state: await toControlDPublicState(config), + }; + } + + if (command.type === CONTROL_D_COMMANDS.preview) { + try { + const prepared = await prepareControlDSync(createClient(apiKey), config); + log( + deps, + "control-d.diff.ready", + { + addRules: prepared.diff.addRules, + updateRules: prepared.diff.updateRules, + deleteRules: prepared.diff.deleteRules, + warnings: prepared.diff.warnings.length, + }, + undefined, + apiKey, + ); + return { + ok: true, + state: await toControlDPublicState(config), + diff: prepared.diff, + proxies: prepared.proxies, + }; + } catch (error) { + const failed = await saveFailure(config, error); + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(failed), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.syncNow && !config.lastSyncedHash) { + return { ok: false, error: "Preview and confirm the first synchronization." }; + } + + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + const result = await runExclusive({ + confirmApproximate: + command.type === CONTROL_D_COMMANDS.apply ? command.confirmApproximate : true, + repair: command.type === CONTROL_D_COMMANDS.repair, + automatic: false, + }); + if (!result) return { ok: false, error: "Synchronization did not run." }; + if (!result.ok) { + return { + ok: false, + error: errorMessage(result.error), + state: await toControlDPublicState(result.failed), + }; + } + return { + ok: true, + state: await toControlDPublicState(result.next), + diff: result.prepared.diff, + }; + }; + + return { respond, scheduleAutomatic }; +}; + +export const registerControlD = (deps: BackgroundEntryDeps): void => { + const controller = createController(deps); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!isControlDCommand(message) || sender.id !== chrome.runtime.id) return false; + fireAndForget(controller.respond(message).then(sendResponse), (error) => + sendResponse({ ok: false, error: errorMessage(error) }), + ); + return true; + }); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if ( + areaName === "local" && + (RULES_STORAGE_KEY in changes || LOCATIONS_STORAGE_KEY in changes) + ) { + controller.scheduleAutomatic(); + } + }); +}; diff --git a/src/experimental/control-d/client.test.ts b/src/experimental/control-d/client.test.ts new file mode 100644 index 0000000..e50fbbf --- /dev/null +++ b/src/experimental/control-d/client.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ControlDClient } from "./client"; + +const jsonResponse = (body: unknown, status = 200, headers?: HeadersInit): Response => + new Response(JSON.stringify(body), { status, ...(headers ? { headers } : {}) }); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ControlDClient", () => { + it("invokes an injected browser transport with the global receiver", async () => { + const browserFetch = vi.fn(function (this: unknown) { + if (this !== globalThis) throw new TypeError("Illegal invocation"); + return Promise.resolve(jsonResponse({ body: { profiles: [] } })); + }); + + await expect( + new ControlDClient( + "token", + browserFetch as unknown as typeof fetch, + ).listProfiles(), + ).resolves.toEqual([]); + expect(browserFetch).toHaveBeenCalledOnce(); + }); + + it("validates proxy fields, ignores unknown fields and excludes hidden exits", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + proxies: [ + { + PK: "WAW", + city: "Warsaw", + country: "pl", + country_name: "Poland", + gps_lat: "52.2", + gps_long: 21, + future_field: true, + }, + { + PK: "SECRET", + city: "Hidden", + country: "PL", + country_name: "Poland", + gps_lat: 1, + gps_long: 2, + hidden: 1, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listProxies()).resolves.toEqual( + [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, + }, + ], + ); + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.controld.com/proxies", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + + it("does not retry authentication failures", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, 401)); + const request = new ControlDClient("bad", fetchImpl).listProfiles(); + + await expect(request).rejects.toMatchObject({ + status: 401, + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("surfaces a sanitized Control D error code, message and operation", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { + body: [], + success: false, + error: { + message: "Name must be a maximum of 32 characters", + code: 40003, + }, + }, + 400, + ), + ); + + await expect( + new ControlDClient("token", fetchImpl).createProfile("too-long"), + ).rejects.toMatchObject({ + message: + "Control D rejected create profile (HTTP 400, code 40003): Name must be a maximum of 32 characters", + status: 400, + operation: "create profile", + apiCode: 40003, + }); + }); + + it("keeps a transport failure reason for redacted debug logging", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("Illegal invocation"); + }); + + await expect( + new ControlDClient("token", fetchImpl as unknown as typeof fetch).createProfile( + "Privacy Thing", + ), + ).rejects.toMatchObject({ + status: 0, + causeMessage: "TypeError: Illegal invocation", + }); + }); + + it("reads supported endpoint types and sends the selected icon", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + body: { + types: { + os: { + name: "Desktop & Mobile", + icons: { "desktop-linux": "Linux" }, + }, + browser: { + name: "Browser", + icons: { + "browser-chrome": "Google Chrome", + "browser-firefox": "Firefox", + "browser-other": "Other Browser", + }, + }, + router: { + name: "Router", + icons: { router: "Router" }, + }, + }, + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + body: { + device: { + PK: "device-1", + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + }, + }), + ); + const client = new ControlDClient("token", fetchImpl as unknown as typeof fetch); + + await expect(client.listDeviceTypes()).resolves.toEqual([ + "desktop-linux", + "browser-chrome", + "browser-firefox", + "browser-other", + "router", + ]); + await expect( + client.createDevice("Privacy Thing", "profile-1", "browser-chromium"), + ).resolves.toMatchObject({ + id: "device-1", + profileId: "profile-1", + resolverDoh: "https://dns.controld.com/secret", + }); + const [, createInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + expect(createInit.body?.toString()).toContain("client_count=1"); + expect(createInit.body?.toString()).toContain("profile_id=profile-1"); + expect(createInit.body?.toString()).toContain("icon=browser-chromium"); + }); + + it("accepts a legacy flat endpoint-type map without treating groups as icons", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ body: { types: { "browser-other": "Other Browser" } } }), + ); + + await expect( + new ControlDClient("token", fetchImpl).listDeviceTypes(), + ).resolves.toEqual(["browser-other"]); + }); + + it("configures the managed profile default as enabled Bypass", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({})); + + await new ControlDClient("token", fetchImpl).setDefaultBypass("profile-1"); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.controld.com/profiles/profile-1/default"); + expect(init.method).toBe("PUT"); + expect(init.body?.toString()).toBe("do=1&status=1"); + }); + + it("parses documented folder and rule list field names", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + body: { + groups: [ + { + PK: 7, + group: "Managed folder", + action: { do: 3, via: "WAW", status: 1 }, + count: 1, + }, + ], + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + body: { + rules: [ + { + PK: "example.com", + group: 7, + action: { do: 3, via: "WAW", status: 1 }, + }, + ], + }, + }), + ); + const client = new ControlDClient("token", fetchImpl as unknown as typeof fetch); + + await expect(client.listGroups("profile-1")).resolves.toEqual([ + { id: 7, name: "Managed folder", action: 3, via: "WAW" }, + ]); + await expect(client.listRules("profile-1", 7)).resolves.toEqual([ + { + hostname: "example.com", + groupId: 7, + action: 3, + via: "WAW", + status: 1, + comment: null, + }, + ]); + }); + + it("respects Retry-After for a safe request", async () => { + vi.useFakeTimers(); + const onRetry = vi.fn(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({}, 429, { "Retry-After": "2" })) + .mockResolvedValueOnce(jsonResponse({ body: { profiles: [] } })); + + const request = new ControlDClient( + "token", + fetchImpl as unknown as typeof fetch, + 12_000, + onRetry, + ).listProfiles(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(request).resolves.toEqual([]); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledWith({ + attempt: 1, + delayMs: 2_000, + status: 429, + requestId: null, + }); + }); +}); diff --git a/src/experimental/control-d/client.ts b/src/experimental/control-d/client.ts new file mode 100644 index 0000000..a11706d --- /dev/null +++ b/src/experimental/control-d/client.ts @@ -0,0 +1,459 @@ +import type { ControlDProxyLocation } from "./contracts"; +import { redactControlDLogValue } from "./redaction"; + +const API_BASE = "https://api.controld.com"; +const MAX_ATTEMPTS = 3; + +type UnknownRecord = Record; + +export class ControlDApiError extends Error { + // eslint-disable-next-line max-params -- Carries the complete sanitized API failure context. + constructor( + message: string, + readonly status: number, + readonly requestId: string | null, + readonly retryAfterSeconds: number | null, + readonly causeMessage: string | null = null, + readonly operation: string | null = null, + readonly apiCode: number | null = null, + ) { + super(message); + this.name = "ControlDApiError"; + } +} + +const isRecord = (value: unknown): value is UnknownRecord => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const asRecord = (value: unknown): UnknownRecord => (isRecord(value) ? value : {}); +const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const asString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; +const asNumber = (value: unknown): number | null => { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const bodyRecord = (payload: unknown): UnknownRecord => + asRecord(asRecord(payload).body); +const extractCollection = (payload: unknown, key: string): unknown[] => { + const body = bodyRecord(payload); + return asArray(body[key] ?? asRecord(body.data)[key] ?? asRecord(payload)[key]); +}; + +const formBody = ( + fields: Record, +): URLSearchParams => { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + value.forEach((entry) => body.append(key, entry)); + } else { + body.set(key, String(value)); + } + } + return body; +}; + +const delay = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +}; + +const parseJson = (text: string): unknown => { + if (!text) return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return {}; + } +}; + +export type ControlDProfile = { id: string; name: string }; +export type ControlDGroup = { + id: number; + name: string; + action: number | null; + via: string | null; +}; +export type ControlDRule = { + hostname: string; + groupId: number | null; + action: number | null; + via: string | null; + status: number | null; + comment: string | null; +}; +export type ControlDDevice = { + id: string; + name: string; + profileId: string | null; + resolverDoh: string | null; +}; +export type ControlDRetryEvent = { + attempt: number; + delayMs: number; + status: number; + requestId: string | null; +}; + +export class ControlDClient { + constructor( + private readonly token: string, + private readonly fetchImpl: typeof fetch = fetch, + private readonly timeoutMs = 12_000, + private readonly onRetry?: (event: ControlDRetryEvent) => void, + ) {} + + // eslint-disable-next-line sonarjs/cognitive-complexity -- Retry, timeout, and HTTP policy stay centralized. + private async request( + path: string, + init: RequestInit = {}, + retryable = false, + operation = "API request", + ): Promise { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl.call(globalThis, `${API_BASE}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + ...(init.body + ? { "Content-Type": "application/x-www-form-urlencoded" } + : {}), + ...init.headers, + }, + signal: controller.signal, + }); + const requestId = + response.headers.get("x-request-id") ?? response.headers.get("cf-ray"); + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfterSeconds = retryAfterHeader + ? Number.parseInt(retryAfterHeader, 10) + : null; + const responseText = response.status === 204 ? "" : await response.text(); + const payload = parseJson(responseText); + + if (!response.ok) { + if ( + retryable && + (response.status === 429 || response.status >= 500) && + attempt < MAX_ATTEMPTS + ) { + const delayMs = + response.status === 429 && Number.isFinite(retryAfterSeconds) + ? Math.max(0, retryAfterSeconds ?? 0) * 1_000 + : 250 * 2 ** (attempt - 1); + this.onRetry?.({ + attempt, + delayMs, + status: response.status, + requestId, + }); + await delay(delayMs); + continue; + } + const apiError = asRecord(asRecord(payload).error); + const apiCode = asNumber(apiError.code); + const rawApiMessage = asString(apiError.message); + const apiMessage = rawApiMessage + ? String(redactControlDLogValue(rawApiMessage, this.token)).slice(0, 240) + : null; + const codeLabel = apiCode === null ? "" : `, code ${apiCode}`; + const detail = apiMessage ? `: ${apiMessage}` : "."; + throw new ControlDApiError( + `Control D rejected ${operation} (HTTP ${response.status}${codeLabel})${detail}`, + response.status, + requestId, + Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null, + null, + operation, + apiCode, + ); + } + + return payload; + } catch (error) { + if (error instanceof ControlDApiError) throw error; + if (retryable && attempt < MAX_ATTEMPTS) { + const delayMs = 250 * 2 ** (attempt - 1); + this.onRetry?.({ attempt, delayMs, status: 0, requestId: null }); + await delay(delayMs); + continue; + } + throw new ControlDApiError( + error instanceof DOMException && error.name === "AbortError" + ? "Control D API request timed out." + : "Control D API request failed.", + 0, + null, + null, + error instanceof Error ? `${error.name}: ${error.message}` : String(error), + ); + } finally { + clearTimeout(timer); + } + } + throw new ControlDApiError("Control D API request failed.", 0, null, null, null); + } + + async listProfiles(): Promise { + const payload = await this.request("/profiles", {}, true, "list profiles"); + return extractCollection(payload, "profiles").flatMap((entry) => { + const record = asRecord(entry); + const id = asString(record.PK ?? record.pk ?? record.id); + const name = asString(record.name); + return id && name ? [{ id, name }] : []; + }); + } + + async createProfile(name: string): Promise { + await this.request( + "/profiles", + { method: "POST", body: formBody({ name }) }, + false, + "create profile", + ); + } + + async setDefaultBypass(profileId: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/default`, + { method: "PUT", body: formBody({ do: 1, status: 1 }) }, + true, + "set profile default", + ); + } + + async listGroups(profileId: string): Promise { + const payload = await this.request( + `/profiles/${encodeURIComponent(profileId)}/groups`, + {}, + true, + "list rule folders", + ); + return extractCollection(payload, "groups").flatMap((entry) => { + const record = asRecord(entry); + const id = asNumber(record.PK ?? record.pk ?? record.id); + const name = asString(record.name ?? record.group); + return id !== null && name + ? [ + { + id, + name, + action: asNumber(record.do ?? asRecord(record.action).do), + via: asString(record.via ?? asRecord(record.action).via), + }, + ] + : []; + }); + } + + async createGroup(profileId: string, name: string, proxyPk: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/groups`, + { + method: "POST", + body: formBody({ name, do: 3, via: proxyPk, status: 1 }), + }, + false, + "create rule folder", + ); + } + + async listRules(profileId: string, folderId: number): Promise { + const payload = await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules/${folderId}`, + {}, + true, + "list managed rules", + ); + return extractCollection(payload, "rules").flatMap((entry) => { + const record = asRecord(entry); + const action = asRecord(record.action); + const hostname = asString(record.hostname ?? record.host ?? record.PK); + if (!hostname) return []; + return [ + { + hostname, + groupId: asNumber(record.group ?? record.group_id), + action: asNumber(record.do ?? action.do), + via: asString(record.via ?? action.via), + status: asNumber(record.status ?? action.status), + comment: asString(record.comment), + }, + ]; + }); + } + + // eslint-disable-next-line max-params -- Mirrors the public API form contract. + async createRules( + profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ): Promise { + if (hostnames.length === 0) return; + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules`, + { + method: "POST", + body: formBody({ + do: 3, + status: 1, + via: proxyPk, + group: folderId, + comment, + "hostnames[]": hostnames, + }), + }, + false, + "create managed rules", + ); + } + + // eslint-disable-next-line max-params -- Mirrors the public API form contract. + async updateRules( + profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ): Promise { + if (hostnames.length === 0) return; + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules`, + { + method: "PUT", + body: formBody({ + do: 3, + status: 1, + via: proxyPk, + group: folderId, + comment, + "hostnames[]": hostnames, + }), + }, + true, + "update managed rules", + ); + } + + async deleteRule(profileId: string, hostname: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules/${encodeURIComponent(hostname)}`, + { method: "DELETE" }, + true, + "delete managed rule", + ); + } + + async listProxies(): Promise { + const payload = await this.request("/proxies", {}, true, "list proxy locations"); + return extractCollection(payload, "proxies").flatMap((entry) => { + const record = asRecord(entry); + const pk = asString(record.PK ?? record.pk ?? record.uid); + const city = asString(record.city); + const countryCode = asString(record.country); + const countryName = asString(record.country_name) ?? countryCode; + const latitude = asNumber(record.gps_lat); + const longitude = asNumber(record.gps_long); + const hidden = record.hidden === true || record.hidden === 1; + return pk && + city && + countryCode && + latitude !== null && + longitude !== null && + !hidden + ? [ + { + pk, + city, + countryCode: countryCode.toUpperCase(), + countryName: countryName ?? countryCode, + latitude, + longitude, + }, + ] + : []; + }); + } + + async listDevices(): Promise { + const payload = await this.request("/devices", {}, true, "list endpoints"); + return extractCollection(payload, "devices").flatMap((entry) => { + const record = asRecord(entry); + const resolvers = asRecord(record.resolvers); + const profile = asRecord(record.profile); + const id = asString(record.PK ?? record.pk ?? record.id); + const name = asString(record.name); + return id && name + ? [ + { + id, + name, + profileId: asString(record.profile_id ?? profile.PK ?? profile.id), + resolverDoh: asString(resolvers.doh ?? record.doh), + }, + ] + : []; + }); + } + + async listDeviceTypes(): Promise { + const payload = await this.request( + "/devices/types", + {}, + true, + "list endpoint types", + ); + const types = bodyRecord(payload).types; + const entries = extractCollection(payload, "types"); + const arrayIds = entries.flatMap((entry) => { + if (typeof entry === "string" && entry) return [entry]; + const record = asRecord(entry); + const value = asString(record.id ?? record.PK ?? record.value ?? record.slug); + return value ? [value] : []; + }); + const typeGroups = asRecord(types); + const flatIds = Object.entries(typeGroups).flatMap(([id, value]) => + typeof value === "string" ? [id] : [], + ); + const nestedIds = Object.values(typeGroups).flatMap((group) => + Object.keys(asRecord(asRecord(group).icons)), + ); + return [...new Set([...arrayIds, ...flatIds, ...nestedIds])]; + } + + async createDevice( + name: string, + profileId: string, + icon: string, + ): Promise { + const payload = await this.request( + "/devices", + { + method: "POST", + body: formBody({ name, client_count: 1, profile_id: profileId, icon }), + }, + false, + "create endpoint", + ); + const candidates = [ + ...extractCollection(payload, "devices"), + bodyRecord(payload).device, + bodyRecord(payload), + ]; + for (const entry of candidates) { + const record = asRecord(entry); + const id = asString(record.PK ?? record.pk ?? record.id); + const resolvers = asRecord(record.resolvers); + const resolverDoh = asString(resolvers.doh ?? record.doh); + if (id) return { id, name, profileId, resolverDoh }; + } + return null; + } +} diff --git a/src/experimental/control-d/compiler.test.ts b/src/experimental/control-d/compiler.test.ts new file mode 100644 index 0000000..4a02883 --- /dev/null +++ b/src/experimental/control-d/compiler.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; + +import { compileControlDPattern, compileControlDState } from "./compiler"; +import type { ControlDProxyLocation } from "./contracts"; + +import type { DomainRule, Location } from "@/shared/types"; + +const warsaw: Location = { + id: "warsaw", + label: "Warsaw", + latitude: 52.23, + longitude: 21.01, + countryCode: "PL", + accuracy: 25, + noiseRadius: 50, + language: "pl", + languages: ["pl"], + timeZone: "Europe/Warsaw", +}; + +const paris: Location = { + ...warsaw, + id: "paris", + label: "Paris", + latitude: 48.86, + longitude: 2.35, + countryCode: "FR", + language: "fr", + languages: ["fr"], + timeZone: "Europe/Paris", +}; + +const ottawa: Location = { + ...warsaw, + id: "ottawa", + label: "Ottawa", + latitude: 45.42, + longitude: -75.7, + countryCode: "CA", + language: "en", + languages: ["en"], + timeZone: "America/Toronto", +}; + +const proxies: ControlDProxyLocation[] = [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, + }, + { + pk: "PAR", + city: "Paris", + countryCode: "FR", + countryName: "France", + latitude: 48.86, + longitude: 2.35, + }, + { + pk: "YOW", + city: "Ottawa", + countryCode: "CA", + countryName: "Canada", + latitude: 45.42, + longitude: -75.7, + }, + { + pk: "BER", + city: "Berlin", + countryCode: "DE", + countryName: "Germany", + latitude: 52.52, + longitude: 13.4, + }, +]; + +const rule = (pattern: string): DomainRule => ({ + pattern, + enabled: true, + locationId: warsaw.id, +}); + +describe("compileControlDPattern", () => { + it("preserves subdomain-only patterns", () => { + expect(compileControlDPattern("*.example.com")).toEqual({ + hostname: "*.example.com", + }); + }); + + it("maps Privacy Thing suffix patterns to apex-and-subdomains", () => { + expect(compileControlDPattern("*example.com")).toEqual({ + hostname: "example.com", + }); + }); + + it("includes exact hosts with a widening warning and rejects unproven wildcards", () => { + expect(compileControlDPattern("example.com")).toMatchObject({ + hostname: "example.com", + warning: { code: "exact-pattern-broadened" }, + }); + expect(compileControlDPattern("server-*.example.com")).toMatchObject({ + warning: { code: "unsupported-pattern" }, + }); + }); + + it("keeps exact hosts in the compiled regional rules", () => { + const result = compileControlDState({ + rules: [rule("www.linkedin.com"), rule("github.com")], + locations: [warsaw], + proxies, + storedMappings: {}, + }); + + expect(result.rules.map((entry) => entry.hostname)).toEqual([ + "github.com", + "www.linkedin.com", + ]); + expect(result.mappings.warsaw).toMatchObject({ + locationLabel: "Warsaw", + ruleCount: 2, + }); + expect(result.warnings).toHaveLength(2); + expect( + result.warnings.every((warning) => warning.code === "exact-pattern-broadened"), + ).toBe(true); + }); +}); + +describe("compileControlDState", () => { + it("compiles the exported Warsaw, Paris, and Ottawa rule set", () => { + const result = compileControlDState({ + rules: [ + rule("www.linkedin.com"), + rule("github.com"), + rule("*www.instagram.com"), + rule("*example.com"), + { pattern: "*jakdojade.pl", enabled: true }, + { ...rule("iteracja.elpassion.com"), locationId: ottawa.id }, + { ...rule("test.pl"), locationId: paris.id }, + ], + locations: [warsaw, paris, ottawa], + proxies, + storedMappings: {}, + }); + + expect(result.rules).toHaveLength(6); + expect(result.rules.map((entry) => entry.hostname)).toEqual([ + "example.com", + "github.com", + "iteracja.elpassion.com", + "test.pl", + "www.instagram.com", + "www.linkedin.com", + ]); + expect(result.mappings).toMatchObject({ + warsaw: { proxyPk: "WAW", ruleCount: 4 }, + paris: { proxyPk: "PAR", ruleCount: 1 }, + ottawa: { proxyPk: "YOW", ruleCount: 1 }, + }); + }); + + it("selects the nearest exit in the confirmed country", () => { + const result = compileControlDState({ + rules: [rule("*example.com")], + locations: [warsaw], + proxies, + storedMappings: {}, + }); + + expect(result.rules).toEqual([ + { + sourcePattern: "*example.com", + hostname: "example.com", + locationId: "warsaw", + proxyPk: "WAW", + }, + ]); + expect(result.mappings.warsaw).toMatchObject({ + status: "exact", + confirmed: true, + proxyPk: "WAW", + }); + }); + + it("uses a visible approximate mapping when the country is unavailable", () => { + const result = compileControlDState({ + rules: [rule("*.example.com")], + locations: [{ ...warsaw, countryCode: "CZ" }], + proxies, + storedMappings: {}, + }); + + expect(result.mappings.warsaw).toMatchObject({ + status: "approximate", + confirmed: false, + }); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: "approximate-location" }), + ); + }); + + it("honors an explicit skip and ignores disabled or location-less rules", () => { + const result = compileControlDState({ + rules: [rule("*example.com"), { ...rule("*off.test"), enabled: false }], + locations: [warsaw], + proxies, + storedMappings: { + warsaw: { + locationId: "warsaw", + proxyPk: null, + status: "skipped", + confirmed: true, + }, + }, + }); + + expect(result.rules).toEqual([]); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: "skipped-location" }), + ); + }); +}); diff --git a/src/experimental/control-d/compiler.ts b/src/experimental/control-d/compiler.ts new file mode 100644 index 0000000..c8a3ef9 --- /dev/null +++ b/src/experimental/control-d/compiler.ts @@ -0,0 +1,278 @@ +/* eslint-disable sonarjs/cognitive-complexity -- Fail-closed compilation keeps rule decisions explicit. */ + +import type { + ControlDCompiledRule, + ControlDCompileWarning, + ControlDMapping, + ControlDProxyLocation, +} from "./contracts"; + +import { getDomainPatternKind } from "@/shared/domain-match"; +import type { DomainRule, Location } from "@/shared/types"; + +export type ControlDCompilation = { + rules: ControlDCompiledRule[]; + warnings: ControlDCompileWarning[]; + mappings: Record; +}; + +const toRadians = (degrees: number): number => (degrees * Math.PI) / 180; + +export const distanceInKilometers = ( + first: Pick, + second: Pick, +): number => { + const earthRadius = 6_371; + const latitudeDelta = toRadians(second.latitude - first.latitude); + const longitudeDelta = toRadians(second.longitude - first.longitude); + const firstLatitude = toRadians(first.latitude); + const secondLatitude = toRadians(second.latitude); + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(firstLatitude) * + Math.cos(secondLatitude) * + Math.sin(longitudeDelta / 2) ** 2; + + return earthRadius * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +}; + +const nearestProxy = ( + location: Location, + proxies: readonly ControlDProxyLocation[], +): ControlDProxyLocation | null => + proxies.reduce((nearest, candidate) => { + if (!nearest) return candidate; + return distanceInKilometers(location, candidate) < + distanceInKilometers(location, nearest) + ? candidate + : nearest; + }, null); + +const resolveMapping = ( + location: Location, + proxies: readonly ControlDProxyLocation[], + stored: ControlDMapping | undefined, +): { mapping: ControlDMapping; warning?: ControlDCompileWarning } => { + if (stored?.status === "skipped" || stored?.proxyPk === null) { + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: null, + status: "skipped", + confirmed: stored?.confirmed ?? true, + }, + warning: { + code: "skipped-location", + locationId: location.id, + message: `${location.label} is excluded from Control D synchronization.`, + }, + }; + } + + const storedProxy = stored + ? proxies.find((proxy) => proxy.pk === stored.proxyPk) + : undefined; + if (storedProxy) { + const exact = + Boolean(location.countryCode) && + storedProxy.countryCode === location.countryCode?.toUpperCase(); + const status = exact ? "exact" : "approximate"; + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: storedProxy.pk, + status, + confirmed: status === "exact" || (stored?.confirmed ?? false), + }, + ...(status === "approximate" + ? { + warning: { + code: "approximate-location" as const, + locationId: location.id, + message: `${location.label} uses the approximate exit ${storedProxy.city}, ${storedProxy.countryName}.`, + }, + } + : {}), + }; + } + + const normalizedCountry = location.countryCode?.toUpperCase(); + const sameCountry = normalizedCountry + ? proxies.filter((proxy) => proxy.countryCode === normalizedCountry) + : []; + const selected = nearestProxy( + location, + sameCountry.length > 0 ? sameCountry : proxies, + ); + + if (!selected) { + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: null, + status: "skipped", + confirmed: false, + }, + warning: { + code: "skipped-location", + locationId: location.id, + message: `No usable Control D exit is available for ${location.label}.`, + }, + }; + } + + const exact = sameCountry.length > 0; + const status = exact ? "exact" : "approximate"; + let warning: ControlDCompileWarning | undefined; + if (!normalizedCountry) { + warning = { + code: "missing-country", + locationId: location.id, + message: `${location.label} has no confirmed country and currently maps to ${selected.city}, ${selected.countryName}.`, + }; + } else if (!exact) { + warning = { + code: "approximate-location", + locationId: location.id, + message: `Control D has no exit in ${normalizedCountry}; ${location.label} maps to ${selected.city}, ${selected.countryName}.`, + }; + } + + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: selected.pk, + status, + confirmed: exact, + }, + ...(warning ? { warning } : {}), + }; +}; + +export const compileControlDPattern = ( + pattern: string, +): + | { hostname: string; warning?: ControlDCompileWarning } + | { warning: ControlDCompileWarning } => { + const normalized = pattern.trim().toLowerCase().replace(/\.$/, ""); + const kind = getDomainPatternKind(normalized); + + if (kind === "subdomains-only" && /^\*\.[a-z0-9.-]+$/.test(normalized)) { + return { hostname: normalized }; + } + + if (kind === "apex-and-subdomains" && /^\*[a-z0-9.-]+$/.test(normalized)) { + return { hostname: normalized.slice(1) }; + } + + if (kind === "exact") { + return { + hostname: normalized, + warning: { + code: "exact-pattern-broadened", + pattern, + message: `${pattern} is exact in Privacy Thing but would include subdomains in Control D.`, + }, + }; + } + + return { + warning: { + code: "unsupported-pattern", + pattern, + message: `${pattern} has wildcard semantics that cannot be proven equivalent in Control D.`, + }, + }; +}; + +export const compileControlDState = ({ + rules, + locations, + proxies, + storedMappings, +}: { + rules: readonly DomainRule[]; + locations: readonly Location[]; + proxies: readonly ControlDProxyLocation[]; + storedMappings: Readonly>; +}): ControlDCompilation => { + const warnings: ControlDCompileWarning[] = []; + const mappings: Record = {}; + const compiledRules: ControlDCompiledRule[] = []; + const locationById = new Map(locations.map((location) => [location.id, location])); + const usedHostnames = new Map(); + + for (const rule of rules) { + if (!rule.enabled || !rule.locationId) continue; + const location = locationById.get(rule.locationId); + if (!location) { + warnings.push({ + code: "missing-location", + pattern: rule.pattern, + locationId: rule.locationId, + message: `${rule.pattern} references a missing regional preset.`, + }); + continue; + } + + let mapping = mappings[location.id]; + if (!mapping) { + const resolved = resolveMapping(location, proxies, storedMappings[location.id]); + mapping = resolved.mapping; + mappings[location.id] = mapping; + if (resolved.warning) warnings.push(resolved.warning); + } + if (!mapping.proxyPk || mapping.status === "skipped") continue; + + const patternResult = compileControlDPattern(rule.pattern); + if (!("hostname" in patternResult)) { + warnings.push({ ...patternResult.warning, locationId: location.id }); + continue; + } + if (patternResult.warning) { + warnings.push({ ...patternResult.warning, locationId: location.id }); + } + + const previousLocation = usedHostnames.get(patternResult.hostname); + if (previousLocation && previousLocation !== location.id) { + warnings.push({ + code: "unsupported-pattern", + pattern: rule.pattern, + locationId: location.id, + message: `${patternResult.hostname} resolves to more than one regional preset.`, + }); + continue; + } + + usedHostnames.set(patternResult.hostname, location.id); + compiledRules.push({ + sourcePattern: rule.pattern, + hostname: patternResult.hostname, + locationId: location.id, + proxyPk: mapping.proxyPk, + }); + } + + const ruleCounts = compiledRules.reduce>((counts, rule) => { + counts[rule.locationId] = (counts[rule.locationId] ?? 0) + 1; + return counts; + }, {}); + const summarizedMappings = Object.fromEntries( + Object.entries(mappings).map(([locationId, mapping]) => [ + locationId, + { ...mapping, ruleCount: ruleCounts[locationId] ?? 0 }, + ]), + ); + + return { + rules: compiledRules.sort((left, right) => + left.hostname.localeCompare(right.hostname), + ), + warnings, + mappings: summarizedMappings, + }; +}; diff --git a/src/experimental/control-d/contracts.ts b/src/experimental/control-d/contracts.ts new file mode 100644 index 0000000..c997207 --- /dev/null +++ b/src/experimental/control-d/contracts.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; + +export const CONTROL_D_API_ORIGIN = "https://api.controld.com/*"; +export const CONTROL_D_GUIDE_URL = "https://docs.controld.com/docs/browsers-platform"; +export const CONTROL_D_STATUS_URL = "https://controld.com/status"; + +export const CONTROL_D_COMMANDS = { + getState: "pt.control-d.get-state", + setEnabled: "pt.control-d.set-enabled", + connect: "pt.control-d.connect", + preview: "pt.control-d.preview", + apply: "pt.control-d.apply", + syncNow: "pt.control-d.sync-now", + repair: "pt.control-d.repair", + updateMapping: "pt.control-d.update-mapping", + dnsAction: "pt.control-d.dns-action", + disconnect: "pt.control-d.disconnect", +} as const; + +export type ControlDStatus = + "disconnected" | "ready" | "syncing" | "conflict" | "auth-error" | "error"; + +export type ControlDMapping = { + locationId: string; + locationLabel?: string; + ruleCount?: number; + proxyPk: string | null; + status: "exact" | "approximate" | "skipped"; + confirmed: boolean; +}; + +export type ControlDManagedFolder = { + proxyPk: string; + folderId: number; + remoteHash: string; +}; + +export type ControlDConfig = { + version: 1; + instanceId: string; + enabled: boolean; + connected: boolean; + autoSyncEnabled: boolean; + status: ControlDStatus; + profileId: string | null; + endpointId: string | null; + resolverDoh: string | null; + managedFolders: Record; + locationMappings: Record; + lastSyncedHash: string | null; + lastAttemptAt: string | null; + lastSuccessAt: string | null; + lastError: string | null; +}; + +export type ControlDProxyLocation = { + pk: string; + city: string; + countryCode: string; + countryName: string; + latitude: number; + longitude: number; +}; + +export type ControlDCompiledRule = { + sourcePattern: string; + hostname: string; + locationId: string; + proxyPk: string; +}; + +export type ControlDCompileWarning = { + code: + | "exact-pattern-broadened" + | "unsupported-pattern" + | "missing-location" + | "missing-country" + | "approximate-location" + | "skipped-location"; + message: string; + pattern?: string; + locationId?: string; +}; + +export type ControlDDiff = { + createProfile: boolean; + createEndpoint: boolean; + createFolders: number; + addRules: number; + updateRules: number; + deleteRules: number; + unchangedRules: number; + warnings: ControlDCompileWarning[]; + mappings: ControlDMapping[]; + requiresApproximationConfirmation: boolean; +}; + +export type ControlDPublicState = { + enabled: boolean; + connected: boolean; + autoSyncEnabled: boolean; + status: ControlDStatus; + hasApiKey: boolean; + profileId: string | null; + endpointId: string | null; + hasResolver: boolean; + resolverDoh: string | null; + locationMappings: ControlDMapping[]; + lastAttemptAt: string | null; + lastSuccessAt: string | null; + lastError: string | null; +}; + +export type ControlDResponse = + | ({ ok: true; state: ControlDPublicState } & (T extends undefined ? object : T)) + | { ok: false; error: string; state?: ControlDPublicState }; + +const mappingSchema = z.object({ + locationId: z.string().min(1), + locationLabel: z.string().min(1).optional(), + ruleCount: z.number().int().nonnegative().optional(), + proxyPk: z.string().min(1).nullable(), + status: z.enum(["exact", "approximate", "skipped"]), + confirmed: z.boolean(), +}); + +const managedFolderSchema = z.object({ + proxyPk: z.string().min(1), + folderId: z.number().int().nonnegative(), + remoteHash: z.string(), +}); + +export const controlDConfigSchema = z.object({ + version: z.literal(1), + instanceId: z.string().min(1), + enabled: z.boolean().optional(), + connected: z.boolean(), + autoSyncEnabled: z.boolean(), + status: z.enum([ + "disconnected", + "ready", + "syncing", + "conflict", + "auth-error", + "error", + ]), + profileId: z.string().min(1).nullable(), + endpointId: z.string().min(1).nullable(), + resolverDoh: z.string().min(1).nullable(), + managedFolders: z.record(z.string(), managedFolderSchema), + locationMappings: z.record(z.string(), mappingSchema), + lastSyncedHash: z.string().nullable(), + lastAttemptAt: z.string().nullable(), + lastSuccessAt: z.string().nullable(), + lastError: z.string().nullable(), +}); + +export type ControlDCommand = + | { type: typeof CONTROL_D_COMMANDS.getState } + | { type: typeof CONTROL_D_COMMANDS.setEnabled; enabled: boolean } + | { type: typeof CONTROL_D_COMMANDS.connect; apiKey: string } + | { type: typeof CONTROL_D_COMMANDS.preview } + | { + type: typeof CONTROL_D_COMMANDS.apply; + confirmApproximate: boolean; + } + | { type: typeof CONTROL_D_COMMANDS.syncNow } + | { type: typeof CONTROL_D_COMMANDS.repair } + | { + type: typeof CONTROL_D_COMMANDS.updateMapping; + mapping: ControlDMapping; + } + | { + type: typeof CONTROL_D_COMMANDS.dnsAction; + action: "copy-resolver" | "open-settings" | "open-status" | "open-guide"; + outcome: "success" | "fallback" | "failure"; + } + | { type: typeof CONTROL_D_COMMANDS.disconnect }; + +export const isControlDCommand = (value: unknown): value is ControlDCommand => { + if (!value || typeof value !== "object") return false; + const type = (value as { type?: unknown }).type; + return ( + typeof type === "string" && + Object.values(CONTROL_D_COMMANDS).includes( + type as (typeof CONTROL_D_COMMANDS)[keyof typeof CONTROL_D_COMMANDS], + ) + ); +}; diff --git a/src/experimental/control-d/reconcile.target.test.ts b/src/experimental/control-d/reconcile.target.test.ts new file mode 100644 index 0000000..d082fc9 --- /dev/null +++ b/src/experimental/control-d/reconcile.target.test.ts @@ -0,0 +1,401 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ControlDClient, + ControlDDevice, + ControlDGroup, + ControlDRule, +} from "./client"; +import type { ControlDConfig, ControlDProxyLocation } from "./contracts"; +import { + applyControlDSync, + ControlDConflictError, + prepareControlDSync, +} from "./reconcile"; + +import { loadLocations } from "@/background/storage/locations"; +import { loadRules } from "@/background/storage/rules"; + +vi.mock("@/background/storage/locations", () => ({ loadLocations: vi.fn() })); +vi.mock("@/background/storage/rules", () => ({ loadRules: vi.fn() })); + +const proxy: ControlDProxyLocation = { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, +}; + +const berlinProxy: ControlDProxyLocation = { + pk: "BER", + city: "Berlin", + countryCode: "DE", + countryName: "Germany", + latitude: 52.52, + longitude: 13.4, +}; + +const config = (): ControlDConfig => ({ + version: 1, + instanceId: "instance-1", + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + profileId: null, + endpointId: null, + resolverDoh: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, +}); + +class FakeClient { + profiles: Array<{ id: string; name: string }> = []; + groups: ControlDGroup[] = []; + devices: ControlDDevice[] = []; + rules = new Map(); + createRulesCalls = 0; + updateRulesCalls = 0; + deleteRuleCalls = 0; + setDefaultBypassCalls = 0; + createdDeviceIcon: string | null = null; + proxies = [proxy]; + failNextCreateRules = false; + + async listProfiles() { + return this.profiles; + } + + async createProfile(name: string) { + this.profiles.push({ id: "profile-1", name }); + } + + async setDefaultBypass() { + this.setDefaultBypassCalls += 1; + } + + async listGroups() { + return this.groups; + } + + async createGroup(_profileId: string, name: string, proxyPk: string) { + const id = this.groups.length + 7; + this.groups.push({ id, name, action: 3, via: proxyPk }); + this.rules.set(id, []); + } + + async listRules(_profileId: string, folderId: number) { + return this.rules.get(folderId) ?? []; + } + + async createRules( + _profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ) { + if (this.failNextCreateRules) { + this.failNextCreateRules = false; + throw new Error("simulated rule write failure"); + } + this.createRulesCalls += hostnames.length; + const current = this.rules.get(folderId) ?? []; + this.rules.set(folderId, [ + ...current, + ...hostnames.map((hostname) => ({ + hostname, + groupId: folderId, + action: 3, + via: proxyPk, + status: 1, + comment, + })), + ]); + } + + async updateRules( + _profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ) { + this.updateRulesCalls += hostnames.length; + const selected = new Set(hostnames); + this.rules.set( + folderId, + (this.rules.get(folderId) ?? []).map((rule) => + selected.has(rule.hostname) + ? { ...rule, action: 3, via: proxyPk, status: 1, comment } + : rule, + ), + ); + } + + async deleteRule(_profileId: string, hostname: string) { + this.deleteRuleCalls += 1; + for (const [folderId, rules] of this.rules) { + this.rules.set( + folderId, + rules.filter((rule) => rule.hostname !== hostname), + ); + } + } + + async listProxies() { + return this.proxies; + } + + async listDevices() { + return this.devices; + } + + async listDeviceTypes() { + return ["browser-chrome", "browser-firefox", "browser-other"]; + } + + async createDevice(name: string, profileId: string, icon: string) { + this.createdDeviceIcon = icon; + const device = { + id: "device-1", + name, + profileId, + resolverDoh: "https://dns.controld.com/secret", + }; + this.devices.push(device); + return device; + } +} + +const asClient = (client: FakeClient): ControlDClient => + client as unknown as ControlDClient; + +beforeEach(() => { + vi.mocked(loadLocations).mockResolvedValue([ + { + id: "warsaw", + label: "Warsaw", + latitude: 52.23, + longitude: 21.01, + countryCode: "PL", + accuracy: 25, + noiseRadius: 50, + language: "pl", + languages: ["pl"], + timeZone: "Europe/Warsaw", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + ]); +}); + +describe("Control D reconcile", () => { + it("creates isolated resources once and is idempotent", async () => { + const fake = new FakeClient(); + const initial = config(); + const firstPrepared = await prepareControlDSync(asClient(fake), initial); + + expect(firstPrepared.diff).toMatchObject({ + createProfile: true, + createEndpoint: true, + createFolders: 1, + addRules: 1, + }); + + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: firstPrepared, + confirmApproximate: false, + repair: false, + }); + expect(applied).toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + autoSyncEnabled: true, + }); + expect(fake.createRulesCalls).toBe(1); + expect(fake.createdDeviceIcon).toBe( + __PT_BROWSER_TARGET__ === "firefox" ? "browser-firefox" : "browser-other", + ); + fake.devices.push({ + id: "device-manual", + name: "Manually attached endpoint", + profileId: "profile-1", + resolverDoh: null, + }); + + const secondPrepared = await prepareControlDSync(asClient(fake), applied); + expect(secondPrepared.diff).toMatchObject({ + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 1, + }); + + await applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: secondPrepared, + confirmApproximate: true, + repair: false, + }); + expect(fake.createRulesCalls).toBe(1); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + expect(fake.devices).toHaveLength(2); + expect(fake.devices).toContainEqual( + expect.objectContaining({ + id: "device-manual", + profileId: "profile-1", + }), + ); + }); + + it("stops before writes when a managed rule drifts remotely", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.rules.set(7, [ + { + ...(fake.rules.get(7)?.[0] as ControlDRule), + comment: "manually changed", + }, + ]); + const drifted = await prepareControlDSync(asClient(fake), applied); + + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: drifted, + confirmApproximate: true, + repair: false, + }), + ).rejects.toBeInstanceOf(ControlDConflictError); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + }); + + it("preflights every managed folder before writing any folder", async () => { + vi.mocked(loadLocations).mockResolvedValue([ + ...(await loadLocations()), + { + id: "berlin", + label: "Berlin", + latitude: 52.52, + longitude: 13.4, + countryCode: "DE", + accuracy: 25, + noiseRadius: 50, + language: "de", + languages: ["de"], + timeZone: "Europe/Berlin", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*example.de", enabled: true, locationId: "berlin" }, + ]); + + const fake = new FakeClient(); + fake.proxies = [proxy, berlinProxy]; + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + const writesBeforeDrift = fake.createRulesCalls; + const defaultsBeforeDrift = fake.setDefaultBypassCalls; + const berlinFolder = applied.managedFolders.BER; + expect(berlinFolder).toBeDefined(); + fake.rules.set(berlinFolder!.folderId, [ + { + ...(fake.rules.get(berlinFolder!.folderId)?.[0] as ControlDRule), + comment: "manually changed", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*new-example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*example.de", enabled: true, locationId: "berlin" }, + ]); + const drifted = await prepareControlDSync(asClient(fake), applied); + + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: drifted, + confirmApproximate: true, + repair: false, + }), + ).rejects.toBeInstanceOf(ControlDConflictError); + expect(fake.createRulesCalls).toBe(writesBeforeDrift); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + expect(fake.setDefaultBypassCalls).toBe(defaultsBeforeDrift); + }); + + it("recovers its uniquely named resources after a partial first failure", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + fake.failNextCreateRules = true; + + await expect( + applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }), + ).rejects.toThrow("simulated rule write failure"); + expect(fake.profiles).toHaveLength(1); + expect(fake.devices).toHaveLength(1); + expect(fake.groups).toHaveLength(1); + + const retryPrepared = await prepareControlDSync(asClient(fake), initial); + const recovered = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: retryPrepared, + confirmApproximate: false, + repair: false, + }); + + expect(recovered).toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + }); + expect(fake.profiles).toHaveLength(1); + expect(fake.devices).toHaveLength(1); + expect(fake.groups).toHaveLength(1); + expect(fake.createRulesCalls).toBe(1); + }); +}); diff --git a/src/experimental/control-d/reconcile.ts b/src/experimental/control-d/reconcile.ts new file mode 100644 index 0000000..ce1bc55 --- /dev/null +++ b/src/experimental/control-d/reconcile.ts @@ -0,0 +1,517 @@ +/* eslint-disable max-lines-per-function, max-params, sonarjs/cognitive-complexity -- Reconcile keeps remote ownership checks in one module. */ +import type { ControlDClient } from "./client"; +import { ControlDApiError, type ControlDRule } from "./client"; +import { compileControlDState, type ControlDCompilation } from "./compiler"; +import type { + ControlDDiff, + ControlDConfig, + ControlDManagedFolder, + ControlDProxyLocation, +} from "./contracts"; +import { + controlDEndpointName, + controlDFolderName, + controlDProfileName, + controlDRuleComment, +} from "./resource-names"; + +import { loadLocations } from "@/background/storage/locations"; +import { loadRules } from "@/background/storage/rules"; + +export class ControlDConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlDConflictError"; + } +} + +export type ControlDPreparedSync = { + compilation: ControlDCompilation; + proxies: ControlDProxyLocation[]; + diff: ControlDDiff; +}; + +const profileName = controlDProfileName; +const endpointName = (instanceId: string): string => + controlDEndpointName(instanceId, __PT_BROWSER_TARGET__); +const folderName = controlDFolderName; +const ruleComment = controlDRuleComment; + +const canonicalRules = (rules: readonly ControlDRule[]): unknown[] => + [...rules] + .sort((left, right) => left.hostname.localeCompare(right.hostname)) + .map((rule) => ({ + hostname: rule.hostname, + groupId: rule.groupId, + action: rule.action, + via: rule.via, + status: rule.status, + comment: rule.comment, + })); + +export const hashControlDValue = async (value: unknown): Promise => { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; + +const desiredByProxy = (compilation: ControlDCompilation): Map => { + const result = new Map(); + for (const rule of compilation.rules) { + const current = result.get(rule.proxyPk) ?? []; + current.push(rule.hostname); + result.set(rule.proxyPk, current); + } + for (const hostnames of result.values()) hostnames.sort(); + return result; +}; + +const compareFolderRules = ( + remoteRules: readonly ControlDRule[], + desiredHostnames: readonly string[], + folderId: number, + proxyPk: string, + expectedComment: string, +): Pick< + ControlDDiff, + "addRules" | "updateRules" | "deleteRules" | "unchangedRules" +> => { + const desired = new Set(desiredHostnames); + const remote = new Map(remoteRules.map((rule) => [rule.hostname, rule])); + let addRules = 0; + let updateRules = 0; + let unchangedRules = 0; + + for (const hostname of desired) { + const rule = remote.get(hostname); + if (!rule) { + addRules += 1; + continue; + } + if ( + (rule.groupId === null || rule.groupId === folderId) && + (rule.action === null || rule.action === 3) && + (rule.via === null || rule.via === proxyPk) && + (rule.status === null || rule.status === 1) && + (rule.comment === null || rule.comment === expectedComment) + ) { + unchangedRules += 1; + } else { + updateRules += 1; + } + } + + return { + addRules, + updateRules, + deleteRules: remoteRules.filter((rule) => !desired.has(rule.hostname)).length, + unchangedRules, + }; +}; + +const emptyCounts = () => ({ + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 0, +}); + +export const prepareControlDSync = async ( + client: ControlDClient, + config: ControlDConfig, +): Promise => { + const [rules, locations, proxies, profiles] = await Promise.all([ + loadRules(), + loadLocations(), + client.listProxies(), + client.listProfiles(), + ]); + if (proxies.length === 0) { + throw new Error("Control D returned no usable proxy locations."); + } + + const compilation = compileControlDState({ + rules, + locations, + proxies, + storedMappings: config.locationMappings, + }); + const desired = desiredByProxy(compilation); + const counts = emptyCounts(); + let createFolders = desired.size; + const expectedProfileName = profileName(config.instanceId); + const knownProfile = config.profileId + ? profiles.find((profile) => profile.id === config.profileId) + : undefined; + + if (config.profileId && !knownProfile) { + throw new ControlDConflictError("The managed Control D profile is missing."); + } + if (knownProfile && knownProfile.name !== expectedProfileName) { + throw new ControlDConflictError("The managed Control D profile was renamed."); + } + + if (knownProfile) { + const groups = await client.listGroups(knownProfile.id); + createFolders = 0; + for (const [proxyPk, hostnames] of desired) { + const managed = config.managedFolders[proxyPk]; + const group = managed + ? groups.find((candidate) => candidate.id === managed.folderId) + : undefined; + if (!group) { + if (managed) { + throw new ControlDConflictError( + `Managed folder ${managed.folderId} is missing.`, + ); + } + createFolders += 1; + counts.addRules += hostnames.length; + continue; + } + if ( + group.name !== folderName(config.instanceId, proxyPk) || + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + const remoteRules = await client.listRules(knownProfile.id, group.id); + const folderCounts = compareFolderRules( + remoteRules, + hostnames, + group.id, + proxyPk, + ruleComment(config.instanceId), + ); + counts.addRules += folderCounts.addRules; + counts.updateRules += folderCounts.updateRules; + counts.deleteRules += folderCounts.deleteRules; + counts.unchangedRules += folderCounts.unchangedRules; + } + + for (const [proxyPk, managed] of Object.entries(config.managedFolders)) { + if (desired.has(proxyPk)) continue; + const group = groups.find((candidate) => candidate.id === managed.folderId); + if (!group) { + throw new ControlDConflictError( + `Managed folder ${managed.folderId} is missing.`, + ); + } + counts.deleteRules += (await client.listRules(knownProfile.id, group.id)).length; + } + } else { + counts.addRules = compilation.rules.length; + } + + const devices = knownProfile ? await client.listDevices() : []; + const knownEndpoint = config.endpointId + ? devices.find((device) => device.id === config.endpointId) + : undefined; + if (config.endpointId && !knownEndpoint) { + throw new ControlDConflictError("The managed Control D endpoint is missing."); + } + if (knownEndpoint && knownEndpoint.name !== endpointName(config.instanceId)) { + throw new ControlDConflictError("The managed Control D endpoint was renamed."); + } + + return { + compilation, + proxies, + diff: { + createProfile: !knownProfile, + createEndpoint: !knownEndpoint, + createFolders, + ...counts, + warnings: compilation.warnings, + mappings: Object.values(compilation.mappings), + requiresApproximationConfirmation: Object.values(compilation.mappings).some( + (mapping) => mapping.status === "approximate" && !mapping.confirmed, + ), + }, + }; +}; + +const requireUniqueProfile = async ( + client: ControlDClient, + name: string, +): Promise => { + const matches = (await client.listProfiles()).filter( + (profile) => profile.name === name, + ); + if (matches.length !== 1 || !matches[0]) { + throw new Error("Could not uniquely identify the managed Control D profile."); + } + return matches[0].id; +}; + +const ensureProfile = async ( + client: ControlDClient, + config: ControlDConfig, +): Promise => { + const name = profileName(config.instanceId); + const profiles = await client.listProfiles(); + if (config.profileId) { + const managed = profiles.find((profile) => profile.id === config.profileId); + if (!managed) throw new ControlDConflictError("The managed profile is missing."); + if (managed.name !== name) { + throw new ControlDConflictError("The managed profile was renamed."); + } + return managed.id; + } + const existing = profiles.filter((profile) => profile.name === name); + if (existing.length > 1) { + throw new ControlDConflictError("More than one managed profile has the same name."); + } + if (existing[0]) return existing[0].id; + await client.createProfile(name); + return requireUniqueProfile(client, name); +}; + +const ensureEndpoint = async ( + client: ControlDClient, + config: ControlDConfig, + profileId: string, +): Promise<{ id: string; resolverDoh: string | null }> => { + if (config.endpointId) { + const existing = (await client.listDevices()).find( + (device) => device.id === config.endpointId, + ); + if (!existing) throw new ControlDConflictError("The managed endpoint is missing."); + if (existing.profileId && existing.profileId !== profileId) { + throw new ControlDConflictError("The managed endpoint uses another profile."); + } + return { id: existing.id, resolverDoh: existing.resolverDoh }; + } + + const name = endpointName(config.instanceId); + const existing = (await client.listDevices()).filter( + (device) => device.name === name, + ); + if (existing.length > 1) { + throw new ControlDConflictError( + "More than one managed endpoint has the same name.", + ); + } + if (existing[0]) { + if (existing[0].profileId && existing[0].profileId !== profileId) { + throw new ControlDConflictError("The recoverable endpoint uses another profile."); + } + return { id: existing[0].id, resolverDoh: existing[0].resolverDoh }; + } + + const types = await client.listDeviceTypes(); + const preferredTypes = + __PT_BROWSER_TARGET__ === "firefox" + ? ["browser-firefox", "browser-other"] + : ["browser-other", "browser-chrome", "browser-edge", "browser-brave"]; + const icon = + preferredTypes.find((candidate) => types.includes(candidate)) ?? + types.find((type) => type.startsWith("browser-")); + if (!icon) throw new Error("Control D returned no supported browser endpoint type."); + const created = await client.createDevice(name, profileId, icon); + if (created) return { id: created.id, resolverDoh: created.resolverDoh }; + const recovered = (await client.listDevices()).filter( + (device) => device.name === name, + ); + if (recovered.length !== 1 || !recovered[0]) { + throw new Error("Could not identify the newly created Control D endpoint."); + } + return { id: recovered[0].id, resolverDoh: recovered[0].resolverDoh }; +}; + +const assertNoDrift = async ( + remoteRules: readonly ControlDRule[], + managed: ControlDManagedFolder | undefined, + repair: boolean, +): Promise => { + if (!managed?.remoteHash || repair) return; + const remoteHash = await hashControlDValue(canonicalRules(remoteRules)); + if (remoteHash !== managed.remoteHash) { + throw new ControlDConflictError( + "Managed Control D rules changed remotely. Review the diff and use repair explicitly.", + ); + } +}; + +export const applyControlDSync = async ({ + client, + config, + prepared, + confirmApproximate, + repair, +}: { + client: ControlDClient; + config: ControlDConfig; + prepared: ControlDPreparedSync; + confirmApproximate: boolean; + repair: boolean; +}): Promise => { + if (prepared.diff.requiresApproximationConfirmation && !confirmApproximate) { + throw new Error("Confirm every approximate location mapping before applying."); + } + + const confirmedMappings = Object.fromEntries( + Object.values(prepared.compilation.mappings).map((mapping) => [ + mapping.locationId, + mapping.status === "approximate" && confirmApproximate + ? { ...mapping, confirmed: true } + : mapping, + ]), + ); + const nextConfig: ControlDConfig = { + ...config, + locationMappings: confirmedMappings, + }; + const profileId = await ensureProfile(client, nextConfig); + const desired = desiredByProxy(prepared.compilation); + const groups = await client.listGroups(profileId); + const preflightRules = new Map(); + for (const [proxyPk, managed] of Object.entries(nextConfig.managedFolders)) { + const group = groups.find((candidate) => candidate.id === managed.folderId); + if (!group) { + throw new ControlDConflictError(`Managed folder ${managed.folderId} is missing.`); + } + if ( + group.name !== folderName(nextConfig.instanceId, proxyPk) || + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + const remoteRules = await client.listRules(profileId, group.id); + await assertNoDrift(remoteRules, managed, repair); + if ( + remoteRules.some((rule) => rule.groupId !== null && rule.groupId !== group.id) + ) { + throw new ControlDConflictError( + `Folder ${group.id} returned rules owned elsewhere.`, + ); + } + preflightRules.set(group.id, remoteRules); + } + + await client.setDefaultBypass(profileId); + const endpoint = await ensureEndpoint(client, nextConfig, profileId); + const managedFolders: Record = { + ...nextConfig.managedFolders, + }; + + for (const [proxyPk, hostnames] of desired) { + const known = managedFolders[proxyPk]; + let group = known + ? groups.find((candidate) => candidate.id === known.folderId) + : undefined; + if (!group) { + if (known) { + throw new ControlDConflictError(`Managed folder ${known.folderId} is missing.`); + } + const name = folderName(nextConfig.instanceId, proxyPk); + const matches = groups.filter((candidate) => candidate.name === name); + if (matches.length > 1) { + throw new ControlDConflictError( + `More than one managed folder exists for ${proxyPk}.`, + ); + } + group = matches[0]; + if (!group) { + await client.createGroup(profileId, name, proxyPk); + const refreshed = (await client.listGroups(profileId)).filter( + (candidate) => candidate.name === name, + ); + if (refreshed.length !== 1 || !refreshed[0]) { + throw new Error(`Could not identify the managed folder for ${proxyPk}.`); + } + group = refreshed[0]; + } + } + if ( + group.name !== folderName(nextConfig.instanceId, proxyPk) || + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + + const remoteRules = + preflightRules.get(group.id) ?? (await client.listRules(profileId, group.id)); + if ( + remoteRules.some((rule) => rule.groupId !== null && rule.groupId !== group.id) + ) { + throw new ControlDConflictError( + `Folder ${group.id} returned rules owned elsewhere.`, + ); + } + + const desiredSet = new Set(hostnames); + const existingSet = new Set(remoteRules.map((rule) => rule.hostname)); + const toDelete = remoteRules + .filter((rule) => !desiredSet.has(rule.hostname)) + .map((rule) => rule.hostname); + const toCreate = hostnames.filter((hostname) => !existingSet.has(hostname)); + const toUpdate = remoteRules + .filter( + (rule) => + desiredSet.has(rule.hostname) && + ((rule.action !== null && rule.action !== 3) || + (rule.via !== null && rule.via !== proxyPk) || + (rule.status !== null && rule.status !== 1) || + (rule.comment !== null && + rule.comment !== ruleComment(nextConfig.instanceId))), + ) + .map((rule) => rule.hostname); + + for (const hostname of toDelete) await client.deleteRule(profileId, hostname); + await client.createRules( + profileId, + group.id, + proxyPk, + toCreate, + ruleComment(nextConfig.instanceId), + ); + await client.updateRules( + profileId, + group.id, + proxyPk, + toUpdate, + ruleComment(nextConfig.instanceId), + ); + + const finalRules = await client.listRules(profileId, group.id); + managedFolders[proxyPk] = { + proxyPk, + folderId: group.id, + remoteHash: await hashControlDValue(canonicalRules(finalRules)), + }; + } + + for (const [proxyPk, managed] of Object.entries(managedFolders)) { + if (desired.has(proxyPk)) continue; + const remoteRules = preflightRules.get(managed.folderId) ?? []; + for (const rule of remoteRules) await client.deleteRule(profileId, rule.hostname); + managedFolders[proxyPk] = { + ...managed, + remoteHash: await hashControlDValue([]), + }; + } + + return { + ...nextConfig, + connected: true, + autoSyncEnabled: true, + status: "ready", + profileId, + endpointId: endpoint.id, + resolverDoh: endpoint.resolverDoh ?? nextConfig.resolverDoh, + managedFolders, + lastSyncedHash: await hashControlDValue(prepared.compilation.rules), + lastAttemptAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + lastError: null, + }; +}; + +export const isControlDAuthError = (error: unknown): boolean => + error instanceof ControlDApiError && (error.status === 401 || error.status === 403); diff --git a/src/experimental/control-d/redaction.test.ts b/src/experimental/control-d/redaction.test.ts new file mode 100644 index 0000000..b42060d --- /dev/null +++ b/src/experimental/control-d/redaction.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { redactControlDLogValue } from "./redaction"; + +describe("redactControlDLogValue", () => { + it("redacts secrets, authorization and resolver URLs recursively", () => { + const secret = "write-token-123"; + const redacted = redactControlDLogValue( + { + apiKey: secret, + header: `Bearer ${secret}`, + resolver: "failed for https://dns.controld.com/secret-resolver-id", + nested: [`failed for ${secret}`], + status: 429, + }, + secret, + ); + + expect(redacted).toEqual({ + apiKey: "[REDACTED]", + header: "Bearer [REDACTED]", + resolver: "failed for [CONTROL_D_URL_REDACTED]", + nested: ["failed for [REDACTED]"], + status: 429, + }); + }); +}); diff --git a/src/experimental/control-d/redaction.ts b/src/experimental/control-d/redaction.ts new file mode 100644 index 0000000..feaec50 --- /dev/null +++ b/src/experimental/control-d/redaction.ts @@ -0,0 +1,25 @@ +const SECRET_FIELD = /authorization|api[-_ ]?key|token|secret/i; +const BEARER = /Bearer\s+\S+/gi; +const CONTROL_D_URL = /https:\/\/[^\s"'<>]*controld\.com\/[^\s"'<>]*/gi; + +const redactString = (value: string, apiKey?: string): string => { + let redacted = value.replace(CONTROL_D_URL, "[CONTROL_D_URL_REDACTED]"); + redacted = redacted.replace(BEARER, "Bearer [REDACTED]"); + if (apiKey) redacted = redacted.split(apiKey).join("[REDACTED]"); + return redacted; +}; + +export const redactControlDLogValue = (value: unknown, apiKey?: string): unknown => { + if (typeof value === "string") return redactString(value, apiKey); + if (Array.isArray(value)) { + return value.map((entry) => redactControlDLogValue(entry, apiKey)); + } + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + SECRET_FIELD.test(key) ? "[REDACTED]" : redactControlDLogValue(entry, apiKey), + ]), + ); +}; diff --git a/src/experimental/control-d/resource-names.test.ts b/src/experimental/control-d/resource-names.test.ts new file mode 100644 index 0000000..e03d66c --- /dev/null +++ b/src/experimental/control-d/resource-names.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + controlDEndpointName, + controlDFolderName, + controlDProfileName, + controlDRuleComment, +} from "./resource-names"; + +const INSTANCE_ID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("Control D resource names", () => { + it("keeps every remote resource name within the live API limit", () => { + const names = [ + controlDProfileName(INSTANCE_ID), + controlDEndpointName(INSTANCE_ID, "chromium"), + controlDEndpointName(INSTANCE_ID, "firefox"), + controlDFolderName(INSTANCE_ID, "an-arbitrarily-long-proxy-primary-key"), + ]; + + expect(names.every((name) => name.length <= 32)).toBe(true); + expect(names.every((name) => /^[\x20-\x7E]+$/.test(name))).toBe(true); + expect(controlDProfileName(INSTANCE_ID).length).toBeLessThan(32); + }); + + it("is stable and distinguishes instances, browsers and proxy locations", () => { + expect(controlDProfileName(INSTANCE_ID)).toBe("Privacy Thing 123e4567e89b12d3"); + expect(controlDEndpointName(INSTANCE_ID, "chromium")).not.toBe( + controlDEndpointName(INSTANCE_ID, "firefox"), + ); + expect(controlDFolderName(INSTANCE_ID, "WAW")).not.toBe( + controlDFolderName(INSTANCE_ID, "LON"), + ); + expect(controlDRuleComment(INSTANCE_ID)).toBe("PT 123e4567e89b12d3"); + }); +}); diff --git a/src/experimental/control-d/resource-names.ts b/src/experimental/control-d/resource-names.ts new file mode 100644 index 0000000..b56a30a --- /dev/null +++ b/src/experimental/control-d/resource-names.ts @@ -0,0 +1,43 @@ +const MAX_RESOURCE_NAME_LENGTH = 32; + +const fnv1a = (value: string): string => { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(value)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +}; + +const instanceKey = (instanceId: string): string => { + const compact = instanceId.replaceAll(/[^a-z0-9]/gi, "").toLowerCase(); + return compact.padEnd(16, fnv1a(instanceId)).slice(0, 16); +}; + +const browserKey = (browserTarget: string): string => { + if (browserTarget === "chromium") return "chr"; + if (browserTarget === "firefox") return "ff"; + return fnv1a(browserTarget).slice(0, 3); +}; + +const assertValidName = (name: string): string => { + if (name.length > MAX_RESOURCE_NAME_LENGTH) { + throw new Error("Generated Control D resource name exceeds 32 characters."); + } + return name; +}; + +export const controlDProfileName = (instanceId: string): string => + assertValidName(`Privacy Thing ${instanceKey(instanceId)}`); + +export const controlDEndpointName = ( + instanceId: string, + browserTarget: string, +): string => + assertValidName(`PT ${browserKey(browserTarget)} ${instanceKey(instanceId)}`); + +export const controlDFolderName = (instanceId: string, proxyPk: string): string => + assertValidName(`PT ${instanceKey(instanceId)} ${fnv1a(proxyPk)}`); + +export const controlDRuleComment = (instanceId: string): string => + `PT ${instanceKey(instanceId)}`; diff --git a/src/experimental/control-d/storage.test.ts b/src/experimental/control-d/storage.test.ts new file mode 100644 index 0000000..e563d73 --- /dev/null +++ b/src/experimental/control-d/storage.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CONTROL_D_STORE_KEYS, + forgetControlDApiKey, + loadControlDApiKey, + loadControlDConfig, + saveControlDApiKey, +} from "./storage"; + +import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; + +const state: Record = {}; + +beforeEach(() => { + for (const key of Object.keys(state)) Reflect.deleteProperty(state, key); + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn(async (key: string) => (key in state ? { [key]: state[key] } : {})), + set: vi.fn(async (value: Record) => + Object.assign(state, value), + ), + remove: vi.fn(async (key: string) => Reflect.deleteProperty(state, key)), + }, + }, + }); +}); + +describe("Control D storage", () => { + it("keeps its API key outside all ordinary settings keys", async () => { + expect( + CONTROL_D_STORE_KEYS.some((key) => + Object.values(EXTENSION_STORAGE_KEYS).includes(key as never), + ), + ).toBe(false); + + await saveControlDApiKey("secret"); + expect(await loadControlDApiKey()).toBe("secret"); + await forgetControlDApiKey(); + expect(await loadControlDApiKey()).toBeNull(); + }); + + it("creates a versioned config without interpreting malformed older data", async () => { + state[CONTROL_D_STORE_KEYS[0]] = { version: 0, profileId: "foreign" }; + + const config = await loadControlDConfig(); + + expect(config).toMatchObject({ + version: 1, + enabled: false, + connected: false, + autoSyncEnabled: false, + profileId: null, + endpointId: null, + }); + expect(config.instanceId).toBeTruthy(); + }); + + it("keeps an existing connected integration visible after adding the feature switch", async () => { + state[CONTROL_D_STORE_KEYS[0]] = { + version: 1, + instanceId: "existing-instance", + connected: true, + autoSyncEnabled: true, + status: "ready", + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + + const config = await loadControlDConfig(); + + expect(config.enabled).toBe(true); + expect(config.connected).toBe(true); + expect(config.profileId).toBe("profile-id"); + }); +}); diff --git a/src/experimental/control-d/storage.ts b/src/experimental/control-d/storage.ts new file mode 100644 index 0000000..d69160c --- /dev/null +++ b/src/experimental/control-d/storage.ts @@ -0,0 +1,95 @@ +import { + controlDConfigSchema, + type ControlDConfig, + type ControlDPublicState, +} from "@/experimental/control-d/contracts"; + +const CONFIG_KEY = "pt.experimental.control-d.config.v1"; +const API_KEY = "pt.experimental.control-d.api-key.v1"; + +const createDefaultConfig = (): ControlDConfig => ({ + version: 1, + instanceId: crypto.randomUUID(), + enabled: false, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + profileId: null, + endpointId: null, + resolverDoh: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, +}); + +export const loadControlDConfig = async (): Promise => { + const stored = await chrome.storage.local.get(CONFIG_KEY); + const parsed = controlDConfigSchema.safeParse(stored[CONFIG_KEY]); + if (parsed.success) { + const locationMappings = Object.fromEntries( + Object.entries(parsed.data.locationMappings).map(([locationId, mapping]) => [ + locationId, + { + locationId: mapping.locationId, + ...(mapping.locationLabel === undefined + ? {} + : { locationLabel: mapping.locationLabel }), + ...(mapping.ruleCount === undefined ? {} : { ruleCount: mapping.ruleCount }), + proxyPk: mapping.proxyPk, + status: mapping.status, + confirmed: mapping.confirmed, + }, + ]), + ); + return { + ...parsed.data, + enabled: parsed.data.enabled ?? parsed.data.connected, + locationMappings, + }; + } + + const config = createDefaultConfig(); + await chrome.storage.local.set({ [CONFIG_KEY]: config }); + return config; +}; + +export const saveControlDConfig = async (config: ControlDConfig): Promise => { + await chrome.storage.local.set({ [CONFIG_KEY]: controlDConfigSchema.parse(config) }); +}; + +export const loadControlDApiKey = async (): Promise => { + const stored = await chrome.storage.local.get(API_KEY); + const value = stored[API_KEY]; + return typeof value === "string" && value.trim() ? value : null; +}; + +export const saveControlDApiKey = async (apiKey: string): Promise => { + await chrome.storage.local.set({ [API_KEY]: apiKey.trim() }); +}; + +export const forgetControlDApiKey = async (): Promise => { + await chrome.storage.local.remove(API_KEY); +}; + +export const toControlDPublicState = async ( + config: ControlDConfig, +): Promise => ({ + enabled: config.enabled, + connected: config.connected, + autoSyncEnabled: config.autoSyncEnabled, + status: config.status, + hasApiKey: (await loadControlDApiKey()) !== null, + profileId: config.profileId, + endpointId: config.endpointId, + hasResolver: config.resolverDoh !== null, + resolverDoh: config.resolverDoh, + locationMappings: Object.values(config.locationMappings), + lastAttemptAt: config.lastAttemptAt, + lastSuccessAt: config.lastSuccessAt, + lastError: config.lastError, +}); + +export const CONTROL_D_STORE_KEYS = [CONFIG_KEY, API_KEY] as const; diff --git a/src/experimental/control-d/sync-queue.test.ts b/src/experimental/control-d/sync-queue.test.ts new file mode 100644 index 0000000..854c272 --- /dev/null +++ b/src/experimental/control-d/sync-queue.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createControlDSyncQueue } from "./sync-queue"; + +describe("createControlDSyncQueue", () => { + it("serializes operations and starts the queued operation with its latest input", async () => { + const queue = createControlDSyncQueue(); + let releaseFirst: (() => void) | undefined; + let latestSnapshot = "old"; + const first = queue.run( + () => + new Promise((resolve) => { + releaseFirst = () => resolve("first"); + }), + ); + const secondOperation = vi.fn(async () => latestSnapshot); + const second = queue.run(secondOperation); + + expect(queue.isBusy()).toBe(true); + expect(secondOperation).not.toHaveBeenCalled(); + latestSnapshot = "newest"; + releaseFirst?.(); + + await expect(first).resolves.toBe("first"); + await expect(second).resolves.toBe("newest"); + expect(secondOperation).toHaveBeenCalledOnce(); + expect(queue.isBusy()).toBe(false); + }); + + it("continues after a failed operation", async () => { + const queue = createControlDSyncQueue(); + + await expect( + queue.run(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(queue.run(async () => "recovered")).resolves.toBe("recovered"); + }); +}); diff --git a/src/experimental/control-d/sync-queue.ts b/src/experimental/control-d/sync-queue.ts new file mode 100644 index 0000000..bfef8c4 --- /dev/null +++ b/src/experimental/control-d/sync-queue.ts @@ -0,0 +1,26 @@ +export const createControlDSyncQueue = () => { + let active: Promise | null = null; + + const run = async (operation: () => Promise): Promise => { + while (active) { + try { + await active; + } catch { + // A failed operation still releases the queue for the newest snapshot. + } + } + + const current = operation(); + active = current; + try { + return await current; + } finally { + if (active === current) active = null; + } + }; + + return { + run, + isBusy: () => active !== null, + }; +}; diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx new file mode 100644 index 0000000..3624eb9 --- /dev/null +++ b/src/experimental/control-d/ui-entry.tsx @@ -0,0 +1,576 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { + CONTROL_D_API_ORIGIN, + CONTROL_D_COMMANDS, + CONTROL_D_GUIDE_URL, + CONTROL_D_STATUS_URL, + type ControlDDiff, + type ControlDMapping, + type ControlDProxyLocation, + type ControlDPublicState, +} from "./contracts"; + +import { Button } from "@/ui/components/ui/button"; +import { Card, CardContent } from "@/ui/components/ui/card"; +import { Input } from "@/ui/components/ui/input"; +import { Switch } from "@/ui/components/ui/switch"; + +type UiResponse = + | { + ok: true; + state: ControlDPublicState; + diff?: ControlDDiff; + proxies?: ControlDProxyLocation[]; + } + | { ok: false; error: string; state?: ControlDPublicState }; + +const send = async (message: unknown): Promise => + (await chrome.runtime.sendMessage(message)) as UiResponse; + +const requestApiAccess = async (): Promise => { + const permission = { origins: [CONTROL_D_API_ORIGIN] }; + if (await chrome.permissions.contains(permission)) return true; + return chrome.permissions.request(permission); +}; + +export const isIntegrationAvailable = (): boolean => + (chrome.runtime.getManifest().optional_host_permissions ?? []).includes( + CONTROL_D_API_ORIGIN, + ); + +const formatTime = (value: string | null): string => + value ? new Date(value).toLocaleString() : "Not yet"; + +const statusLabel = (state: ControlDPublicState | null): string => { + if (!state) return "Loading"; + const labels: Record = { + disconnected: "Not connected", + ready: "Connected", + syncing: "Synchronizing", + conflict: "Needs attention", + "auth-error": "Authorization failed", + error: "Sync error", + }; + return labels[state.status]; +}; + +const Metric = ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+

+ {label} +

+

{value}

+
+); + +export const ControlDFeatureToggle = ({ + onEnabledChange, +}: { + onEnabledChange: (enabled: boolean) => void; +}) => { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void send({ type: CONTROL_D_COMMANDS.getState }).then((response) => { + if (response.state) { + setState(response.state); + onEnabledChange(response.state.enabled); + } + }); + }, [onEnabledChange]); + + const toggle = async (enabled: boolean) => { + setBusy(true); + try { + const response = await send({ type: CONTROL_D_COMMANDS.setEnabled, enabled }); + if (response.state) setState(response.state); + if (response.ok) onEnabledChange(enabled); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Control D integration

+ + Beta / local + +
+

+ Show the Control D workspace and enable one-way regional rule sync. +

+
+ void toggle(enabled)} + /> +
+ ); +}; + +const DiffSummary = ({ diff }: { diff: ControlDDiff }) => ( +
+ + + + +
+); + +const ResolverSetup = ({ resolver }: { resolver: string }) => { + const [copied, setCopied] = useState(false); + const recordAction = ( + action: "copy-resolver" | "open-settings" | "open-status" | "open-guide", + outcome: "success" | "fallback" | "failure", + ) => void send({ type: CONTROL_D_COMMANDS.dnsAction, action, outcome }); + + const copyResolver = async () => { + try { + await navigator.clipboard.writeText(resolver); + setCopied(true); + recordAction("copy-resolver", "success"); + } catch { + recordAction("copy-resolver", "failure"); + } + }; + + const openBrowserDns = async () => { + const settingsUrl = + __PT_BROWSER_TARGET__ === "firefox" + ? "about:preferences#privacy" + : "chrome://settings/security"; + try { + await chrome.tabs.create({ url: settingsUrl }); + recordAction("open-settings", "success"); + } catch { + await chrome.tabs.create({ url: CONTROL_D_GUIDE_URL }); + recordAction("open-settings", "fallback"); + } + }; + + const openExternal = async (action: "open-status" | "open-guide", url: string) => { + try { + await chrome.tabs.create({ url }); + recordAction(action, "success"); + } catch { + recordAction(action, "failure"); + } + }; + + return ( +
+
+ + 1 + +
+

Copy your private DoH address

+

+ The value stays masked here and is never written to debug logs. +

+
+ + {resolver.replace(/^(https:\/\/[^/]+\/).+$/, "$1••••••••")} + + +
+
+
+
+ + 2 + +
+

Set Secure DNS in your browser

+

+ Browser extensions cannot change this setting on your behalf. +

+
+ + +
+
+
+
+ + 3 + +
+

Verify the active resolver

+

+ Disconnecting Privacy Thing leaves your browser DNS unchanged. +

+ +
+
+
+ ); +}; + +// eslint-disable-next-line max-lines-per-function, sonarjs/cognitive-complexity -- Progressive disclosure keeps this experimental control plane readable. +export const ControlDPanel = () => { + const [state, setState] = useState(null); + const [apiKey, setApiKey] = useState(""); + const [diff, setDiff] = useState(null); + const [proxies, setProxies] = useState([]); + const [confirmApproximate, setConfirmApproximate] = useState(false); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + const run = useCallback(async (message: unknown) => { + setBusy(true); + setNotice(null); + try { + const response = await send(message); + if (response.state) setState(response.state); + if (!response.ok) { + setNotice(response.error); + return response; + } + if (response.diff) setDiff(response.diff); + if (response.proxies) setProxies(response.proxies); + return response; + } catch (error) { + setNotice(error instanceof Error ? error.message : "Control D request failed."); + return null; + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + void run({ type: CONTROL_D_COMMANDS.getState }); + }, [run]); + + const proxyByPk = useMemo( + () => new Map(proxies.map((proxy) => [proxy.pk, proxy])), + [proxies], + ); + + const connect = async () => { + if (!(await requestApiAccess())) { + setNotice("Access to the Control D API was not granted."); + return; + } + const response = await run({ type: CONTROL_D_COMMANDS.connect, apiKey }); + if (response?.ok) { + setApiKey(""); + setNotice("API key verified. It is stored only on this device."); + } + }; + + const updateMapping = async (mapping: ControlDMapping, proxyPk: string) => { + const proxy = proxyByPk.get(proxyPk); + const shared = { + locationId: mapping.locationId, + ...(mapping.locationLabel ? { locationLabel: mapping.locationLabel } : {}), + ...(mapping.ruleCount === undefined ? {} : { ruleCount: mapping.ruleCount }), + }; + const next: ControlDMapping = proxy + ? { + ...shared, + proxyPk, + status: "approximate", + confirmed: false, + } + : { + ...shared, + proxyPk: null, + status: "skipped", + confirmed: true, + }; + const response = await run({ + type: CONTROL_D_COMMANDS.updateMapping, + mapping: next, + }); + if (response?.ok) await run({ type: CONTROL_D_COMMANDS.preview }); + }; + + const activeRoutes = + diff?.mappings.filter((mapping) => mapping.status !== "skipped").length ?? + state?.locationMappings.filter((mapping) => mapping.status !== "skipped").length ?? + 0; + + return ( + +
+
+
+
+

+ Control D regional DNS +

+ + Experimental + +
+

+ Publish compatible regional rules to an isolated Control D profile. Sync + is one-way and never modifies unrelated profiles or rules. +

+
+ + {statusLabel(state)} + +
+
+ + + {!state?.connected ? ( +
+
+

Connect your Control D account

+

+ Use a write-enabled API key. Privacy Thing stores it locally and + excludes it from sync and settings exports. +

+
+
+ setApiKey(event.target.value)} + /> + +
+
+ ) : ( + <> +
+ + + +
+ +
+ + {state.autoSyncEnabled ? ( + + ) : null} + {state.status === "conflict" ? ( + + ) : null} + +
+ + {diff ? : null} + + {diff?.mappings.length ? ( +
+
+

Regional routes

+

+ Review every Privacy Thing profile and the Control D exit it will + use. +

+
+
+ {diff.mappings.map((mapping) => { + const selected = mapping.proxyPk + ? proxyByPk.get(mapping.proxyPk) + : undefined; + return ( +
+
+
+ + {mapping.locationLabel ?? mapping.locationId} + + + {mapping.ruleCount ?? 0}{" "} + {(mapping.ruleCount ?? 0) === 1 ? "rule" : "rules"} + + + {mapping.status} + +
+

+ {selected + ? `${selected.city}, ${selected.countryName} · ${selected.pk}` + : "Excluded from synchronization"} +

+
+ +
+ ); + })} +
+
+ ) : null} + + {diff?.warnings.length ? ( +
+

+ Review before syncing +

+
    + {diff.warnings.map((warning, index) => ( +
  • + • {warning.message} +
  • + ))} +
+
+ ) : null} + + {diff && !state.autoSyncEnabled ? ( +
+

+ Apply the first synchronization +

+

+ After this confirmed apply, Privacy Thing will synchronize the latest + valid snapshot automatically. +

+ {diff.requiresApproximationConfirmation ? ( + + ) : null} + +
+ ) : null} + +

+ Last attempt: {formatTime(state.lastAttemptAt)} + {state.lastError ? ` · ${state.lastError}` : ""} +

+ + )} + + {state?.resolverDoh ? : null} + + {notice ? ( +

+ {notice} +

+ ) : null} +
+
+ ); +}; diff --git a/src/scripts/manifest-config.target.test.ts b/src/scripts/manifest-config.target.test.ts index 5bdffe0..9b234d1 100644 --- a/src/scripts/manifest-config.target.test.ts +++ b/src/scripts/manifest-config.target.test.ts @@ -24,6 +24,21 @@ describe("extension manifest", () => { expect(resolveBrandDisplayName("stable")).toBe("Privacy Thing (Preview)"); }); + it("declares Control D API access only for beta and local builds", () => { + const releaseManifest = createManifest({ buildChannel: "release" }); + const betaManifest = createManifest({ buildChannel: "beta" }); + const localManifest = createManifest({ buildChannel: "local" }); + + expect(releaseManifest).not.toHaveProperty("optional_host_permissions"); + expect(betaManifest.optional_host_permissions).toEqual([ + "https://api.controld.com/*", + ]); + expect(localManifest.optional_host_permissions).toEqual([ + "https://api.controld.com/*", + ]); + expect(releaseManifest.permissions).not.toContain("proxy"); + }); + it("declares Firefox toolbar theme icons without adding them to Chromium", () => { const firefoxManifest = createManifest({ browserTarget: "firefox" }); const chromiumManifest = createManifest({ browserTarget: "chromium" }); diff --git a/src/shared/profile-schema.ts b/src/shared/profile-schema.ts index 608b418..33e4dde 100644 --- a/src/shared/profile-schema.ts +++ b/src/shared/profile-schema.ts @@ -90,6 +90,11 @@ export const locationProfileSchema = z.object({ label: z.string().min(1), latitude: z.number().min(-90).max(90), longitude: z.number().min(-180).max(180), + countryCode: z + .string() + .regex(/^[A-Za-z]{2}$/) + .transform((value) => value.toUpperCase()) + .optional(), accuracy: z.number().positive(), noiseRadius: z.number().nonnegative().optional().default(50), language: z.string().min(2), diff --git a/src/shared/shared-model-types.ts b/src/shared/shared-model-types.ts index 2e5ed59..ee56294 100644 --- a/src/shared/shared-model-types.ts +++ b/src/shared/shared-model-types.ts @@ -223,6 +223,8 @@ export type Location = { label: string; latitude: number; longitude: number; + /** ISO 3166-1 alpha-2 country code used by optional regional integrations. */ + countryCode?: string; accuracy: number; noiseRadius: number; language: string; @@ -236,6 +238,7 @@ export type ProfileDraft = { label: string; latitude: number; longitude: number; + countryCode?: string; accuracy: number; noiseRadius: number; language: string; diff --git a/src/stubs/experimental-control-d-background.ts b/src/stubs/experimental-control-d-background.ts new file mode 100644 index 0000000..56bb2db --- /dev/null +++ b/src/stubs/experimental-control-d-background.ts @@ -0,0 +1,2 @@ +export const registerControlD = (_deps: { getDebugMode: () => boolean }): void => + undefined; diff --git a/src/stubs/experimental-control-d-ui.tsx b/src/stubs/experimental-control-d-ui.tsx new file mode 100644 index 0000000..56d5ae5 --- /dev/null +++ b/src/stubs/experimental-control-d-ui.tsx @@ -0,0 +1,7 @@ +export const ControlDFeatureToggle = (_props: { + onEnabledChange: (enabled: boolean) => void; +}) => null; + +export const ControlDPanel = () => null; + +export const isIntegrationAvailable = (): boolean => false; diff --git a/src/ui/i18n/en-sections/common.ts b/src/ui/i18n/en-sections/common.ts index 9291247..149dfb9 100644 --- a/src/ui/i18n/en-sections/common.ts +++ b/src/ui/i18n/en-sections/common.ts @@ -29,6 +29,7 @@ export const common = { name: "Name", latitude: "Latitude", longitude: "Longitude", + countryCode: "Country code", accuracy: "Accuracy", noiseRadius: "Max radius (m)", timeZone: "Time zone", diff --git a/src/ui/options/components/modals/LocationDetailsFields.tsx b/src/ui/options/components/modals/LocationDetailsFields.tsx index a71fb3f..b370ab4 100644 --- a/src/ui/options/components/modals/LocationDetailsFields.tsx +++ b/src/ui/options/components/modals/LocationDetailsFields.tsx @@ -28,6 +28,7 @@ type LocationFieldsDraft = { label: string; latitude: number; longitude: number; + countryCode?: string; accuracy: number; noiseRadius: number; language: string; @@ -75,6 +76,34 @@ const NameField = ({ ); +const CountryCodeField = ({ + draft, + onDraftChange, + disabled, +}: LocationFieldsProps) => ( +
+ + {t.common.fields.countryCode} + + { + const value = event.currentTarget.value.replace(/[^a-z]/gi, "").toUpperCase(); + onDraftChange((current) => { + const next = { ...current }; + if (value) next.countryCode = value; + else delete next.countryCode; + return next; + }); + }} + /> +
+); + const GeolocationFields = ({ draft, onDraftChange, @@ -136,6 +165,11 @@ const GeolocationFields = ({ /> +
{ ); }; -const ExperimentalCard = () => { +const ExperimentalCard = ({ + onIntegrationToggle, +}: { + onIntegrationToggle: (enabled: boolean) => void; +}) => { const { featureFlags, highlightedAnchorId, @@ -169,6 +178,9 @@ const ExperimentalCard = () => { /> } /> + {isExperimentalIntegrationAvailable() ? ( + + ) : null} ); @@ -346,11 +358,13 @@ const DangerCard = () => { const AdvancedOverview = () => { const { highlightedAnchorId } = useSettings(); + const [controlDEnabled, setControlDEnabled] = React.useState(false); return (
- + + {controlDEnabled ? : null}
diff --git a/src/ui/options/state/use-settings-locations.ts b/src/ui/options/state/use-settings-locations.ts index 2fec272..85cb32e 100644 --- a/src/ui/options/state/use-settings-locations.ts +++ b/src/ui/options/state/use-settings-locations.ts @@ -234,6 +234,7 @@ const commitGeneratedLocation = async ( label: draft.label, latitude: draft.latitude, longitude: draft.longitude, + ...(draft.countryCode ? { countryCode: draft.countryCode } : {}), accuracy: draft.accuracy, noiseRadius: draft.noiseRadius, language: draft.language, diff --git a/tests/build-contracts/chromium-build.test.ts b/tests/build-contracts/chromium-build.test.ts index d3c6108..f3a1620 100644 --- a/tests/build-contracts/chromium-build.test.ts +++ b/tests/build-contracts/chromium-build.test.ts @@ -13,11 +13,13 @@ import { } from "../../config/build-budgets"; import { BRAND_DISPLAY_NAME } from "../../scripts/brand-config.mjs"; +import { findControlDReleaseLeaks } from "./experimental-integrations"; import { findRetiredBuildLeaks } from "./retired-name"; type ChromiumManifest = { version?: string; version_name?: string; + optional_host_permissions?: string[]; content_scripts?: Array<{ all_frames?: boolean; js?: string[]; @@ -81,6 +83,14 @@ test("contains no retired namespace outside approved notification copy", async ( expect(await findRetiredBuildLeaks("chrome")).toEqual([]); }); +test("keeps the Control D experiment out of release artifacts", async () => { + const manifest = await readChromiumManifest(); + if (manifest.version_name) return; + + expect(manifest.optional_host_permissions).toBeUndefined(); + expect(await findControlDReleaseLeaks("chrome")).toEqual([]); +}); + test("exposes only the runtime-applied marker to downstream CI jobs", async () => { const markerPath = path.resolve( process.cwd(), diff --git a/tests/build-contracts/experimental-integrations.ts b/tests/build-contracts/experimental-integrations.ts new file mode 100644 index 0000000..bdb5a73 --- /dev/null +++ b/tests/build-contracts/experimental-integrations.ts @@ -0,0 +1,41 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const CONTROL_D_MARKERS = [ + "Control D", + "ControlD", + "controld.com", + "control-d", + "pt.control-d", + "experimental/control-d", +] as const; + +const listFiles = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() ? listFiles(entryPath) : [entryPath]; + }), + ); + return nested.flat(); +}; + +export const findControlDReleaseLeaks = async ( + target: "chrome" | "firefox", +): Promise> => { + const root = path.resolve(process.cwd(), "build", target); + const files = await listFiles(root); + const leaks: Array<{ file: string; marker: string }> = []; + + for (const file of files) { + const content = await readFile(file); + const text = content.toString("utf8"); + for (const marker of CONTROL_D_MARKERS) { + if (text.includes(marker)) { + leaks.push({ file: path.relative(root, file), marker }); + } + } + } + return leaks; +}; diff --git a/tests/build-contracts/firefox-build.test.ts b/tests/build-contracts/firefox-build.test.ts index 8f6197d..579f873 100644 --- a/tests/build-contracts/firefox-build.test.ts +++ b/tests/build-contracts/firefox-build.test.ts @@ -14,11 +14,13 @@ import { STABLE_FX_EXT_ID, } from "../../scripts/brand-config.mjs"; +import { findControlDReleaseLeaks } from "./experimental-integrations"; import { findRetiredBuildLeaks } from "./retired-name"; type FirefoxManifest = { version?: string; version_name?: string; + optional_host_permissions?: string[]; background?: { scripts?: string[]; service_worker?: string; @@ -60,6 +62,14 @@ test("contains no retired namespace outside approved Firefox IDs and notificatio expect(await findRetiredBuildLeaks("firefox")).toEqual([]); }); +test("keeps the Control D experiment out of release artifacts", async () => { + const manifest = await readFirefoxManifest(); + if (manifest.version_name) return; + + expect(manifest.optional_host_permissions).toBeUndefined(); + expect(await findControlDReleaseLeaks("firefox")).toEqual([]); +}); + test("builds a firefox artifact with gecko settings and script-injection fallback", async () => { const manifest = await readFirefoxManifest(); const isNonReleaseBuild = Boolean(manifest.version_name); diff --git a/tests/e2e/extension-options-navigation.spec.ts b/tests/e2e/extension-options-navigation.spec.ts index 6401cc5..45dcf5b 100644 --- a/tests/e2e/extension-options-navigation.spec.ts +++ b/tests/e2e/extension-options-navigation.spec.ts @@ -14,6 +14,7 @@ import { expectAnchorInViewport, importSettings, openSettingsTab, + saveSimpleSettings, } from "./extension-test.helpers"; import { expect, test } from "./fixtures"; @@ -32,6 +33,31 @@ test("loads the options page from the extension", async ({ context, extensionId await expect(page.locator("#rules-preview-hostname-preview")).toHaveCount(0); await openSettingsTab(page, "advanced"); await expect(page.locator("#export-settings")).toBeVisible(); + const hasControlDPermission = await page.evaluate(() => + (chrome.runtime.getManifest().optional_host_permissions ?? []).includes( + "https://api.controld.com/*", + ), + ); + const controlDToggle = page.getByRole("switch", { + name: "Enable Control D integration", + }); + await expect(controlDToggle).toHaveCount(hasControlDPermission ? 1 : 0); + if (hasControlDPermission) { + await expect( + page.getByRole("heading", { name: "Control D regional DNS" }), + ).toHaveCount(0); + await controlDToggle.click(); + await expect( + page.getByRole("heading", { name: "Control D regional DNS" }), + ).toBeVisible(); + await expect(page.getByLabel("Control D API key")).toHaveAttribute( + "type", + "password", + ); + await expect( + page.locator("span").filter({ hasText: /^Experimental$/ }), + ).toBeVisible(); + } await openSettingsTab(page, "about"); await expect(page.locator("#about-version")).toHaveText(/^\d+\.\d+/); await expect(page.getByRole("link", { name: "Tomasz Janusz" })).toHaveAttribute( @@ -51,6 +77,32 @@ test("loads the options page from the extension", async ({ context, extensionId ).toHaveCount(0); }); +test("shows Control D actions in View Logs when debug mode is enabled", async ({ + context, + extensionId, +}) => { + const page = await context.newPage(); + const optionsUrl = `chrome-extension://${extensionId}/src/ui/options/index.html`; + await page.goto(optionsUrl); + await saveSimpleSettings(page, { debugMode: true }); + await openSettingsTab(page, "advanced"); + + const controlDToggle = page.getByRole("switch", { + name: "Enable Control D integration", + }); + if ((await controlDToggle.count()) === 0) return; + + await controlDToggle.click(); + await expect( + page.getByRole("heading", { name: "Control D regional DNS" }), + ).toBeVisible(); + await page.goto(`${optionsUrl}#page-logs`); + + await expect( + page.getByText("control-d.integration.toggled", { exact: true }), + ).toBeVisible(); +}); + test("keeps the selected settings tab in the URL across reloads", async ({ context, extensionId, From b66885e035636c1199bb04cc4233bda4b0a5a045 Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:06 +0200 Subject: [PATCH 03/11] fix(control-d): restore ID-based sync Accept Control D name normalization after resource IDs are stored. Recover legacy false conflicts, restart automatic sync, and align the experimental UI with shared settings components. --- CHANGELOG.md | 4 + .../control-d/background-entry.target.test.ts | 66 ++ .../control-d/background-entry.ts | 39 +- .../control-d/reconcile.target.test.ts | 51 +- src/experimental/control-d/reconcile.ts | 50 +- src/experimental/control-d/storage.test.ts | 33 + src/experimental/control-d/storage.ts | 24 +- src/experimental/control-d/ui-entry.tsx | 697 +++++++++--------- src/ui/options/stories/ControlD.stories.tsx | 212 ++++++ .../e2e/extension-options-navigation.spec.ts | 8 +- 10 files changed, 812 insertions(+), 372 deletions(-) create mode 100644 src/ui/options/stories/ControlD.stories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3f3e7..2291fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format is based on Keep a Changelog and the project follows Semantic Version ### Fixed +- Keep Control D synchronization attached to resources by their saved IDs when + the API normalizes display names, restore automatic sync after the obsolete + name conflict, expose preview failures in debug logs, and align the Advanced + interface with the existing settings component system. - Confirm installed Battery protection before a page first queries the API, and clear stale integrity evidence after the protection recovers. diff --git a/src/experimental/control-d/background-entry.target.test.ts b/src/experimental/control-d/background-entry.target.test.ts index 5fe70dd..aae6c21 100644 --- a/src/experimental/control-d/background-entry.target.test.ts +++ b/src/experimental/control-d/background-entry.target.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerControlD } from "./background-entry"; import { CONTROL_D_COMMANDS } from "./contracts"; +import { CONTROL_D_STORE_KEYS } from "./storage"; import { logExtensionEvent } from "@/background/logger"; @@ -71,4 +72,69 @@ describe("Control D background entry", () => { ); }); }); + + it("logs regional mapping changes for View Logs in debug mode", async () => { + registerControlD({ getDebugMode: async () => true }); + + const response = await new Promise((resolve) => { + messageListener( + { + type: CONTROL_D_COMMANDS.updateMapping, + mapping: { + locationId: "ottawa", + proxyPk: "YUL", + status: "approximate", + confirmed: false, + }, + }, + { id: "extension-id" }, + resolve, + ); + }); + + expect(response).toMatchObject({ ok: true }); + await vi.waitFor(() => { + expect(logExtensionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + event: "control-d.mapping.updated", + payload: { + details: { + locationId: "ottawa", + proxyPk: "YUL", + status: "approximate", + }, + }, + }), + ); + }); + }); + + it("schedules automatic reconciliation when an applied integration starts", async () => { + storageState[CONTROL_D_STORE_KEYS[0]] = { + version: 1, + instanceId: "existing-instance", + enabled: true, + connected: true, + autoSyncEnabled: true, + status: "ready", + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + registerControlD({ getDebugMode: async () => true }); + + await vi.waitFor(() => { + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1_500); + }); + timeoutSpy.mockRestore(); + }); }); diff --git a/src/experimental/control-d/background-entry.ts b/src/experimental/control-d/background-entry.ts index 0565a23..148ac3e 100644 --- a/src/experimental/control-d/background-entry.ts +++ b/src/experimental/control-d/background-entry.ts @@ -332,6 +332,11 @@ const createController = (deps: BackgroundEntryDeps) => { }, }; await saveControlDConfig(next); + log(deps, "control-d.mapping.updated", { + locationId: command.mapping.locationId, + proxyPk: command.mapping.proxyPk, + status: command.mapping.status, + }); return { ok: true, state: await toControlDPublicState(next) }; } @@ -359,6 +364,14 @@ const createController = (deps: BackgroundEntryDeps) => { if (command.type === CONTROL_D_COMMANDS.preview) { try { const prepared = await prepareControlDSync(createClient(apiKey), config); + const ready: ControlDConfig = { + ...config, + autoSyncEnabled: config.lastSyncedHash ? true : config.autoSyncEnabled, + status: "ready", + lastAttemptAt: new Date().toISOString(), + lastError: null, + }; + await saveControlDConfig(ready); log( deps, "control-d.diff.ready", @@ -373,12 +386,23 @@ const createController = (deps: BackgroundEntryDeps) => { ); return { ok: true, - state: await toControlDPublicState(config), + state: await toControlDPublicState(ready), diff: prepared.diff, proxies: prepared.proxies, }; } catch (error) { const failed = await saveFailure(config, error); + log( + deps, + "control-d.diff.failure", + { + error: errorMessage(error), + ...apiErrorDetails(error), + conflict: error instanceof ControlDConflictError, + }, + ExtensionLogLevel.Error, + apiKey, + ); return { ok: false, error: errorMessage(error), @@ -422,6 +446,19 @@ const createController = (deps: BackgroundEntryDeps) => { export const registerControlD = (deps: BackgroundEntryDeps): void => { const controller = createController(deps); + fireAndForget( + loadControlDConfig().then((config) => { + if ( + config.enabled && + config.connected && + config.autoSyncEnabled && + config.lastSyncedHash + ) { + controller.scheduleAutomatic(); + } + }), + ); + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!isControlDCommand(message) || sender.id !== chrome.runtime.id) return false; fireAndForget(controller.respond(message).then(sendResponse), (error) => diff --git a/src/experimental/control-d/reconcile.target.test.ts b/src/experimental/control-d/reconcile.target.test.ts index d082fc9..3bc1ddb 100644 --- a/src/experimental/control-d/reconcile.target.test.ts +++ b/src/experimental/control-d/reconcile.target.test.ts @@ -165,7 +165,7 @@ class FakeClient { this.createdDeviceIcon = icon; const device = { id: "device-1", - name, + name: name.toLowerCase().replaceAll(" ", "-"), profileId, resolverDoh: "https://dns.controld.com/secret", }; @@ -264,6 +264,55 @@ describe("Control D reconcile", () => { ); }); + it("uses saved resource IDs when Control D normalizes their names", async () => { + const fake = new FakeClient(); + const initial = config(); + const firstPrepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: firstPrepared, + confirmApproximate: false, + repair: false, + }); + + fake.profiles[0]!.name = "privacy-thing-profile"; + fake.groups[0]!.name = "privacy-thing-folder"; + + const secondPrepared = await prepareControlDSync(asClient(fake), applied); + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: secondPrepared, + confirmApproximate: true, + repair: false, + }), + ).resolves.toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + }); + }); + + it("rejects a saved endpoint reassigned to another profile", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.devices[0]!.profileId = "foreign-profile"; + + await expect(prepareControlDSync(asClient(fake), applied)).rejects.toThrow( + "uses another profile", + ); + }); + it("stops before writes when a managed rule drifts remotely", async () => { const fake = new FakeClient(); const initial = config(); diff --git a/src/experimental/control-d/reconcile.ts b/src/experimental/control-d/reconcile.ts index ce1bc55..999fe74 100644 --- a/src/experimental/control-d/reconcile.ts +++ b/src/experimental/control-d/reconcile.ts @@ -36,6 +36,15 @@ const endpointName = (instanceId: string): string => controlDEndpointName(instanceId, __PT_BROWSER_TARGET__); const folderName = controlDFolderName; const ruleComment = controlDRuleComment; +const normalizedResourceName = (name: string): string => + name + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-|-$/g, ""); +const resourceNameMatches = (actual: string, expected: string): boolean => + actual === expected || + normalizedResourceName(actual) === normalizedResourceName(expected); const canonicalRules = (rules: readonly ControlDRule[]): unknown[] => [...rules] @@ -141,7 +150,6 @@ export const prepareControlDSync = async ( const desired = desiredByProxy(compilation); const counts = emptyCounts(); let createFolders = desired.size; - const expectedProfileName = profileName(config.instanceId); const knownProfile = config.profileId ? profiles.find((profile) => profile.id === config.profileId) : undefined; @@ -149,10 +157,6 @@ export const prepareControlDSync = async ( if (config.profileId && !knownProfile) { throw new ControlDConflictError("The managed Control D profile is missing."); } - if (knownProfile && knownProfile.name !== expectedProfileName) { - throw new ControlDConflictError("The managed Control D profile was renamed."); - } - if (knownProfile) { const groups = await client.listGroups(knownProfile.id); createFolders = 0; @@ -172,7 +176,6 @@ export const prepareControlDSync = async ( continue; } if ( - group.name !== folderName(config.instanceId, proxyPk) || (group.action !== null && group.action !== 3) || (group.via !== null && group.via !== proxyPk) ) { @@ -213,8 +216,10 @@ export const prepareControlDSync = async ( if (config.endpointId && !knownEndpoint) { throw new ControlDConflictError("The managed Control D endpoint is missing."); } - if (knownEndpoint && knownEndpoint.name !== endpointName(config.instanceId)) { - throw new ControlDConflictError("The managed Control D endpoint was renamed."); + if (knownEndpoint?.profileId && knownEndpoint.profileId !== knownProfile?.id) { + throw new ControlDConflictError( + "The managed Control D endpoint uses another profile.", + ); } return { @@ -238,8 +243,8 @@ const requireUniqueProfile = async ( client: ControlDClient, name: string, ): Promise => { - const matches = (await client.listProfiles()).filter( - (profile) => profile.name === name, + const matches = (await client.listProfiles()).filter((profile) => + resourceNameMatches(profile.name, name), ); if (matches.length !== 1 || !matches[0]) { throw new Error("Could not uniquely identify the managed Control D profile."); @@ -256,12 +261,11 @@ const ensureProfile = async ( if (config.profileId) { const managed = profiles.find((profile) => profile.id === config.profileId); if (!managed) throw new ControlDConflictError("The managed profile is missing."); - if (managed.name !== name) { - throw new ControlDConflictError("The managed profile was renamed."); - } return managed.id; } - const existing = profiles.filter((profile) => profile.name === name); + const existing = profiles.filter((profile) => + resourceNameMatches(profile.name, name), + ); if (existing.length > 1) { throw new ControlDConflictError("More than one managed profile has the same name."); } @@ -287,8 +291,8 @@ const ensureEndpoint = async ( } const name = endpointName(config.instanceId); - const existing = (await client.listDevices()).filter( - (device) => device.name === name, + const existing = (await client.listDevices()).filter((device) => + resourceNameMatches(device.name, name), ); if (existing.length > 1) { throw new ControlDConflictError( @@ -313,8 +317,8 @@ const ensureEndpoint = async ( if (!icon) throw new Error("Control D returned no supported browser endpoint type."); const created = await client.createDevice(name, profileId, icon); if (created) return { id: created.id, resolverDoh: created.resolverDoh }; - const recovered = (await client.listDevices()).filter( - (device) => device.name === name, + const recovered = (await client.listDevices()).filter((device) => + resourceNameMatches(device.name, name), ); if (recovered.length !== 1 || !recovered[0]) { throw new Error("Could not identify the newly created Control D endpoint."); @@ -375,7 +379,6 @@ export const applyControlDSync = async ({ throw new ControlDConflictError(`Managed folder ${managed.folderId} is missing.`); } if ( - group.name !== folderName(nextConfig.instanceId, proxyPk) || (group.action !== null && group.action !== 3) || (group.via !== null && group.via !== proxyPk) ) { @@ -409,7 +412,9 @@ export const applyControlDSync = async ({ throw new ControlDConflictError(`Managed folder ${known.folderId} is missing.`); } const name = folderName(nextConfig.instanceId, proxyPk); - const matches = groups.filter((candidate) => candidate.name === name); + const matches = groups.filter((candidate) => + resourceNameMatches(candidate.name, name), + ); if (matches.length > 1) { throw new ControlDConflictError( `More than one managed folder exists for ${proxyPk}.`, @@ -418,8 +423,8 @@ export const applyControlDSync = async ({ group = matches[0]; if (!group) { await client.createGroup(profileId, name, proxyPk); - const refreshed = (await client.listGroups(profileId)).filter( - (candidate) => candidate.name === name, + const refreshed = (await client.listGroups(profileId)).filter((candidate) => + resourceNameMatches(candidate.name, name), ); if (refreshed.length !== 1 || !refreshed[0]) { throw new Error(`Could not identify the managed folder for ${proxyPk}.`); @@ -428,7 +433,6 @@ export const applyControlDSync = async ({ } } if ( - group.name !== folderName(nextConfig.instanceId, proxyPk) || (group.action !== null && group.action !== 3) || (group.via !== null && group.via !== proxyPk) ) { diff --git a/src/experimental/control-d/storage.test.ts b/src/experimental/control-d/storage.test.ts index e563d73..96cc25c 100644 --- a/src/experimental/control-d/storage.test.ts +++ b/src/experimental/control-d/storage.test.ts @@ -81,4 +81,37 @@ describe("Control D storage", () => { expect(config.connected).toBe(true); expect(config.profileId).toBe("profile-id"); }); + + it("recovers automatic sync disabled by the obsolete endpoint-name conflict", async () => { + state[CONTROL_D_STORE_KEYS[0]] = { + version: 1, + instanceId: "existing-instance", + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "conflict", + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T14:23:54.000Z", + lastSuccessAt: "2026-09-10T11:09:48.000Z", + lastError: "The managed Control D endpoint was renamed.", + }; + + const config = await loadControlDConfig(); + + expect(config).toMatchObject({ + autoSyncEnabled: true, + status: "ready", + lastError: null, + }); + expect(state[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + autoSyncEnabled: true, + status: "ready", + lastError: null, + }); + }); }); diff --git a/src/experimental/control-d/storage.ts b/src/experimental/control-d/storage.ts index d69160c..e63aa42 100644 --- a/src/experimental/control-d/storage.ts +++ b/src/experimental/control-d/storage.ts @@ -7,6 +7,12 @@ import { const CONFIG_KEY = "pt.experimental.control-d.config.v1"; const API_KEY = "pt.experimental.control-d.api-key.v1"; +const OBSOLETE_NAME_CONFLICTS = new Set([ + "The managed Control D endpoint was renamed.", + "The managed Control D profile was renamed.", + "The managed profile was renamed.", +]); + const createDefaultConfig = (): ControlDConfig => ({ version: 1, instanceId: crypto.randomUUID(), @@ -44,11 +50,27 @@ export const loadControlDConfig = async (): Promise => { }, ]), ); - return { + const config: ControlDConfig = { ...parsed.data, enabled: parsed.data.enabled ?? parsed.data.connected, locationMappings, }; + if ( + config.connected && + config.lastSyncedHash && + config.lastError && + OBSOLETE_NAME_CONFLICTS.has(config.lastError) + ) { + const recovered: ControlDConfig = { + ...config, + autoSyncEnabled: true, + status: "ready", + lastError: null, + }; + await saveControlDConfig(recovered); + return recovered; + } + return config; } const config = createDefaultConfig(); diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx index 3624eb9..047b802 100644 --- a/src/experimental/control-d/ui-entry.tsx +++ b/src/experimental/control-d/ui-entry.tsx @@ -11,10 +11,30 @@ import { type ControlDPublicState, } from "./contracts"; +import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; +import { SettingsSectionCard } from "@/ui/components/SettingsSectionCard"; +import { SettingsSubcard } from "@/ui/components/SettingsSubcard"; +import { Badge } from "@/ui/components/ui/badge"; import { Button } from "@/ui/components/ui/button"; -import { Card, CardContent } from "@/ui/components/ui/card"; +import { Checkbox } from "@/ui/components/ui/checkbox"; import { Input } from "@/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/ui/components/ui/select"; +import { Separator } from "@/ui/components/ui/separator"; import { Switch } from "@/ui/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/ui/components/ui/table"; type UiResponse = | { @@ -42,27 +62,36 @@ export const isIntegrationAvailable = (): boolean => const formatTime = (value: string | null): string => value ? new Date(value).toLocaleString() : "Not yet"; -const statusLabel = (state: ControlDPublicState | null): string => { - if (!state) return "Loading"; - const labels: Record = { - disconnected: "Not connected", - ready: "Connected", - syncing: "Synchronizing", - conflict: "Needs attention", - "auth-error": "Authorization failed", - error: "Sync error", +type StatusVariant = "outline" | "success" | "warning" | "error" | "info"; + +const statusPresentation = ( + state: ControlDPublicState | null, +): { label: string; variant: StatusVariant } => { + if (!state) return { label: "Loading", variant: "outline" }; + const states: Record< + ControlDPublicState["status"], + { label: string; variant: StatusVariant } + > = { + disconnected: { label: "Not connected", variant: "outline" }, + ready: { label: "Connected", variant: "success" }, + syncing: { label: "Synchronizing", variant: "info" }, + conflict: { label: "Needs attention", variant: "warning" }, + "auth-error": { label: "Authorization failed", variant: "error" }, + error: { label: "Sync error", variant: "error" }, }; - return labels[state.status]; + return states[state.status]; }; -const Metric = ({ label, value }: { label: string; value: React.ReactNode }) => ( -
-

- {label} -

-

{value}

-
-); +const mappingBadgeVariant = ( + status: ControlDMapping["status"], +): "outline" | "success" | "warning" => { + if (status === "approximate") return "warning"; + if (status === "skipped") return "outline"; + return "success"; +}; + +const ruleCountLabel = (count: number): string => + `${count} ${count === 1 ? "rule" : "rules"}`; export const ControlDFeatureToggle = ({ onEnabledChange, @@ -93,35 +122,41 @@ export const ControlDFeatureToggle = ({ }; return ( -
-
-
-

Control D integration

- - Beta / local - -
-

- Show the Control D workspace and enable one-way regional rule sync. -

-
- void toggle(enabled)} - /> -
+ + Control D integration + Beta / local + + } + description="Show a separate Control D section for one-way regional DNS synchronization." + focusControlOnTitleClick + action={ + void toggle(enabled)} + /> + } + /> ); }; const DiffSummary = ({ diff }: { diff: ControlDDiff }) => ( -
- - - - -
+
+ {[ + ["Folders to create", diff.createFolders], + ["Rules to add", diff.addRules], + ["Rules to update", diff.updateRules], + ["Unchanged rules", diff.unchangedRules], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
); const ResolverSetup = ({ resolver }: { resolver: string }) => { @@ -165,85 +200,60 @@ const ResolverSetup = ({ resolver }: { resolver: string }) => { }; return ( -
-
- - 1 + +

Browser DNS

+ Manual setup
-
-

Copy your private DoH address

-

- The value stays masked here and is never written to debug logs. -

-
- - {resolver.replace(/^(https:\/\/[^/]+\/).+$/, "$1••••••••")} - - -
-
-
-
- - 2 - -
-

Set Secure DNS in your browser

-

- Browser extensions cannot change this setting on your behalf. -

-
- - -
-
-
-
- - 3 - -
-

Verify the active resolver

-

- Disconnecting Privacy Thing leaves your browser DNS unchanged. -

- -
+ } + description="Secure DNS remains a browser setting. Privacy Thing cannot change it automatically." + > +
+ + {resolver.replace(/^(https:\/\/[^/]+\/).+$/, "$1••••••••")} + + + + +
-
+

+ Disconnecting the integration does not revert your browser DNS setting. +

+ ); }; -// eslint-disable-next-line max-lines-per-function, sonarjs/cognitive-complexity -- Progressive disclosure keeps this experimental control plane readable. +// eslint-disable-next-line max-lines-per-function, sonarjs/cognitive-complexity -- The container coordinates the experimental control plane. export const ControlDPanel = () => { const [state, setState] = useState(null); const [apiKey, setApiKey] = useState(""); @@ -282,6 +292,19 @@ export const ControlDPanel = () => { () => new Map(proxies.map((proxy) => [proxy.pk, proxy])), [proxies], ); + const presentation = statusPresentation(state); + const visibleError = notice ?? state?.lastError ?? null; + const hasAppliedSync = Boolean(state?.lastSuccessAt); + let syncModeLabel = "Manual"; + let syncDescription = + "Preview and apply the first synchronization to activate automatic updates."; + if (state?.autoSyncEnabled) { + syncModeLabel = "Automatic"; + syncDescription = `Automatic sync is active · Last successful sync: ${formatTime(state.lastSuccessAt)}`; + } else if (hasAppliedSync) { + syncModeLabel = "Paused"; + syncDescription = `Automatic sync is paused · Last successful sync: ${formatTime(state?.lastSuccessAt ?? null)}`; + } const connect = async () => { if (!(await requestApiAccess())) { @@ -303,18 +326,8 @@ export const ControlDPanel = () => { ...(mapping.ruleCount === undefined ? {} : { ruleCount: mapping.ruleCount }), }; const next: ControlDMapping = proxy - ? { - ...shared, - proxyPk, - status: "approximate", - confirmed: false, - } - : { - ...shared, - proxyPk: null, - status: "skipped", - confirmed: true, - }; + ? { ...shared, proxyPk, status: "approximate", confirmed: false } + : { ...shared, proxyPk: null, status: "skipped", confirmed: true }; const response = await run({ type: CONTROL_D_COMMANDS.updateMapping, mapping: next, @@ -322,80 +335,58 @@ export const ControlDPanel = () => { if (response?.ok) await run({ type: CONTROL_D_COMMANDS.preview }); }; - const activeRoutes = - diff?.mappings.filter((mapping) => mapping.status !== "skipped").length ?? - state?.locationMappings.filter((mapping) => mapping.status !== "skipped").length ?? - 0; - return ( - -
-
-
-
-

- Control D regional DNS -

- - Experimental - -
-

- Publish compatible regional rules to an isolated Control D profile. Sync - is one-way and never modifies unrelated profiles or rules. -

+ +

Control D

+ Experimental + + } + description="Publish compatible regional rules to an isolated Control D profile. Synchronization is one-way." + headerActions={{presentation.label}} + data-control-d-state={state?.status ?? "loading"} + > + {!state?.connected ? ( + Connect your account} + description="Use a write-enabled API key. It stays in local extension storage and is excluded from export and browser sync." + > +
+ setApiKey(event.target.value)} + /> +
- - {statusLabel(state)} - -
-
- - - {!state?.connected ? ( -
-
-

Connect your Control D account

-

- Use a write-enabled API key. Privacy Thing stores it locally and - excludes it from sync and settings exports. -

-
-
- setApiKey(event.target.value)} - /> - -
-
- ) : ( - <> -
- - - -
- -
+ + ) : ( + <> + +

Synchronization

+ + {syncModeLabel} + + + } + description={syncDescription} + > +
) : null} +
+

+ Last attempt: {formatTime(state.lastAttemptAt)} +

+
+ + {visibleError ? ( +
+ {visibleError} +
+ ) : null} + + {diff ? ( + Pending changes} + description="This preview is read-only. Review all routes before applying." + > + + + ) : null} + + {diff?.mappings.length ? ( + Regional routes} + description="Each Privacy Thing location maps to one Control D exit." + > +
+ + + + Privacy Thing profile + Control D exit + + + + {diff.mappings.map((mapping) => ( + + +
+ {mapping.locationLabel ?? mapping.locationId} +
+
+ + {ruleCountLabel(mapping.ruleCount ?? 0)} + + + {mapping.status} + +
+
+ + + +
+ ))} +
+
+
+
+ ) : null} + + {diff?.warnings.length ? ( + +

Review required

+ {diff.warnings.length} + + } + description="Some rules or locations could not be mapped exactly." + > +
    + {diff.warnings.map((warning, index) => ( +
  • + • {warning.message} +
  • + ))} +
+
+ ) : null} + + {diff && !state.autoSyncEnabled ? ( + Apply first synchronization + } + description="The first write is always explicit. Later valid settings changes synchronize automatically." + > + {diff.requiresApproximationConfirmation ? ( + + ) : null} + + + ) : null} + + {state.resolverDoh ? : null} + + + Connection} + description="Disconnecting forgets the API key and stops synchronization. Remote resources and browser DNS remain unchanged." + > +
- - {diff ? : null} - - {diff?.mappings.length ? ( -
-
-

Regional routes

-

- Review every Privacy Thing profile and the Control D exit it will - use. -

-
-
- {diff.mappings.map((mapping) => { - const selected = mapping.proxyPk - ? proxyByPk.get(mapping.proxyPk) - : undefined; - return ( -
-
-
- - {mapping.locationLabel ?? mapping.locationId} - - - {mapping.ruleCount ?? 0}{" "} - {(mapping.ruleCount ?? 0) === 1 ? "rule" : "rules"} - - - {mapping.status} - -
-

- {selected - ? `${selected.city}, ${selected.countryName} · ${selected.pk}` - : "Excluded from synchronization"} -

-
- -
- ); - })} -
-
- ) : null} - - {diff?.warnings.length ? ( -
-

- Review before syncing -

-
    - {diff.warnings.map((warning, index) => ( -
  • - • {warning.message} -
  • - ))} -
-
- ) : null} - - {diff && !state.autoSyncEnabled ? ( -
-

- Apply the first synchronization -

-

- After this confirmed apply, Privacy Thing will synchronize the latest - valid snapshot automatically. -

- {diff.requiresApproximationConfirmation ? ( - - ) : null} - -
- ) : null} - -

- Last attempt: {formatTime(state.lastAttemptAt)} - {state.lastError ? ` · ${state.lastError}` : ""} -

- - )} - - {state?.resolverDoh ? : null} - - {notice ? ( -

- {notice} -

- ) : null} - - +
+ + )} + + {!state?.connected && visibleError ? ( +
+ {visibleError} +
+ ) : null} + ); }; diff --git a/src/ui/options/stories/ControlD.stories.tsx b/src/ui/options/stories/ControlD.stories.tsx new file mode 100644 index 0000000..4b152c9 --- /dev/null +++ b/src/ui/options/stories/ControlD.stories.tsx @@ -0,0 +1,212 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import { + CONTROL_D_COMMANDS, + type ControlDDiff, + type ControlDPublicState, +} from "../../../experimental/control-d/contracts"; +import { ControlDPanel } from "../../../experimental/control-d/ui-entry"; + +import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; +import { DEFAULT_PREFERENCES } from "@/shared/settings-defaults"; +import { ThemeProvider } from "@/ui/shared/ThemeProvider"; + +const baseState: ControlDPublicState = { + enabled: true, + connected: true, + autoSyncEnabled: true, + status: "ready", + hasApiKey: true, + profileId: "profile-1", + endpointId: "device-1", + hasResolver: true, + resolverDoh: "https://dns.controld.com/private-resolver-token", + locationMappings: [], + lastAttemptAt: "2026-09-10T14:28:00.000Z", + lastSuccessAt: "2026-09-10T14:28:00.000Z", + lastError: null, +}; + +const diff: ControlDDiff = { + createProfile: false, + createEndpoint: false, + createFolders: 2, + addRules: 5, + updateRules: 0, + deleteRules: 0, + unchangedRules: 3, + warnings: [ + { + code: "approximate-location", + message: "Ottawa uses the nearest available Control D exit in Montreal.", + locationId: "ottawa", + }, + ], + mappings: [ + { + locationId: "warsaw", + locationLabel: "Warsaw", + ruleCount: 3, + proxyPk: "WAW", + status: "exact", + confirmed: true, + }, + { + locationId: "paris", + locationLabel: "Paris", + ruleCount: 2, + proxyPk: "PAR", + status: "exact", + confirmed: true, + }, + { + locationId: "ottawa", + locationLabel: "Ottawa", + ruleCount: 1, + proxyPk: "YUL", + status: "approximate", + confirmed: false, + }, + ], + requiresApproximationConfirmation: true, +}; + +const proxies = [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.23, + longitude: 21.01, + }, + { + pk: "PAR", + city: "Paris", + countryCode: "FR", + countryName: "France", + latitude: 48.86, + longitude: 2.35, + }, + { + pk: "YUL", + city: "Montreal", + countryCode: "CA", + countryName: "Canada", + latitude: 45.5, + longitude: -73.57, + }, +]; + +const installBoundary = (state: ControlDPublicState): void => { + Reflect.set(globalThis, "chrome", { + runtime: { + id: "storybook-control-d", + sendMessage: async (message: { type?: string }) => { + if (message.type === CONTROL_D_COMMANDS.preview) { + return { ok: true, state, diff, proxies }; + } + return { ok: true, state }; + }, + getManifest: () => ({ + optional_host_permissions: ["https://api.controld.com/*"], + }), + }, + permissions: { + contains: async () => true, + request: async () => true, + }, + storage: { + local: { + get: async () => ({ + [EXTENSION_STORAGE_KEYS.preferences]: DEFAULT_PREFERENCES, + }), + set: async () => undefined, + remove: async () => undefined, + }, + onChanged: { + addListener: () => undefined, + removeListener: () => undefined, + }, + }, + tabs: { create: async () => undefined }, + }); +}; + +const Surface = ({ state }: { state: ControlDPublicState }) => { + installBoundary(state); + return ( + +
+ +
+
+ ); +}; + +const meta = { + title: "Options/Control D", + component: ControlDPanel, + parameters: { layout: "fullscreen", privacyThing: { surface: "options" } }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Disconnected: Story = { + render: () => ( + + ), +}; + +export const Ready: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Preview changes" }), + ); + await expect(await canvas.findByText("Regional routes")).toBeVisible(); + }, +}; + +export const Conflict: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => + expect( + canvas.getByRole("button", { name: "Repair managed rules" }), + ).toBeVisible(), + ); + }, +}; + +export const Syncing: Story = { + render: () => , +}; diff --git a/tests/e2e/extension-options-navigation.spec.ts b/tests/e2e/extension-options-navigation.spec.ts index 45dcf5b..109ead6 100644 --- a/tests/e2e/extension-options-navigation.spec.ts +++ b/tests/e2e/extension-options-navigation.spec.ts @@ -44,18 +44,18 @@ test("loads the options page from the extension", async ({ context, extensionId await expect(controlDToggle).toHaveCount(hasControlDPermission ? 1 : 0); if (hasControlDPermission) { await expect( - page.getByRole("heading", { name: "Control D regional DNS" }), + page.getByRole("heading", { name: "Control D", exact: true }), ).toHaveCount(0); await controlDToggle.click(); await expect( - page.getByRole("heading", { name: "Control D regional DNS" }), + page.getByRole("heading", { name: "Control D", exact: true }), ).toBeVisible(); await expect(page.getByLabel("Control D API key")).toHaveAttribute( "type", "password", ); await expect( - page.locator("span").filter({ hasText: /^Experimental$/ }), + page.locator("[data-control-d-state]").getByText("Experimental", { exact: true }), ).toBeVisible(); } await openSettingsTab(page, "about"); @@ -94,7 +94,7 @@ test("shows Control D actions in View Logs when debug mode is enabled", async ({ await controlDToggle.click(); await expect( - page.getByRole("heading", { name: "Control D regional DNS" }), + page.getByRole("heading", { name: "Control D", exact: true }), ).toBeVisible(); await page.goto(`${optionsUrl}#page-logs`); From ae4546f7746b696e205cfd2c0e9007e494f56aef Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:09:52 +0200 Subject: [PATCH 04/11] fix(control-d): show matched regional exits --- CHANGELOG.md | 4 +- src/experimental/control-d/ui-entry.tsx | 297 ++++++++---------- .../control-d/ui-regional-route.tsx | 111 +++++++ src/ui/options/stories/ControlD.stories.tsx | 113 +++++-- .../e2e/extension-options-navigation.spec.ts | 4 +- 5 files changed, 337 insertions(+), 192 deletions(-) create mode 100644 src/experimental/control-d/ui-regional-route.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2291fe2..19f3ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ The format is based on Keep a Changelog and the project follows Semantic Version - Keep Control D synchronization attached to resources by their saved IDs when the API normalizes display names, restore automatic sync after the obsolete - name conflict, expose preview failures in debug logs, and align the Advanced - interface with the existing settings component system. + name conflict, expose preview failures in debug logs, and show automatically + matched regional exits explicitly with actionable review and override controls. - Confirm installed Battery protection before a page first queries the API, and clear stale integrity evidence after the protection recovers. diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx index 047b802..cbfc762 100644 --- a/src/experimental/control-d/ui-entry.tsx +++ b/src/experimental/control-d/ui-entry.tsx @@ -10,31 +10,17 @@ import { type ControlDProxyLocation, type ControlDPublicState, } from "./contracts"; +import { ControlDRegionalRoute } from "./ui-regional-route"; import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; import { SettingsSectionCard } from "@/ui/components/SettingsSectionCard"; import { SettingsSubcard } from "@/ui/components/SettingsSubcard"; -import { Badge } from "@/ui/components/ui/badge"; import { Button } from "@/ui/components/ui/button"; import { Checkbox } from "@/ui/components/ui/checkbox"; import { Input } from "@/ui/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/ui/components/ui/select"; import { Separator } from "@/ui/components/ui/separator"; import { Switch } from "@/ui/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/ui/components/ui/table"; +import { PAGE_ANCHORS } from "@/ui/options/navigation"; type UiResponse = | { @@ -62,36 +48,44 @@ export const isIntegrationAvailable = (): boolean => const formatTime = (value: string | null): string => value ? new Date(value).toLocaleString() : "Not yet"; -type StatusVariant = "outline" | "success" | "warning" | "error" | "info"; - -const statusPresentation = ( - state: ControlDPublicState | null, -): { label: string; variant: StatusVariant } => { - if (!state) return { label: "Loading", variant: "outline" }; - const states: Record< - ControlDPublicState["status"], - { label: string; variant: StatusVariant } - > = { - disconnected: { label: "Not connected", variant: "outline" }, - ready: { label: "Connected", variant: "success" }, - syncing: { label: "Synchronizing", variant: "info" }, - conflict: { label: "Needs attention", variant: "warning" }, - "auth-error": { label: "Authorization failed", variant: "error" }, - error: { label: "Sync error", variant: "error" }, +const statusLabel = (state: ControlDPublicState | null): string => { + if (!state) return "Loading"; + const states: Record = { + disconnected: "Not connected", + ready: "Connected", + syncing: "Synchronizing", + conflict: "Needs attention", + "auth-error": "Authorization failed", + error: "Sync error", }; return states[state.status]; }; -const mappingBadgeVariant = ( - status: ControlDMapping["status"], -): "outline" | "success" | "warning" => { - if (status === "approximate") return "warning"; - if (status === "skipped") return "outline"; - return "success"; +const previewSummary = (diff: ControlDDiff): string => { + const changedRuleCount = diff.addRules + diff.updateRules + diff.deleteRules; + if (changedRuleCount === 0 && !diff.createProfile && !diff.createEndpoint) { + return "Everything is up to date."; + } + + const ruleVerb = changedRuleCount === 1 ? "rule is" : "rules are"; + const routeNoun = diff.mappings.length === 1 ? "route" : "routes"; + return `${changedRuleCount} ${ruleVerb} ready to synchronize across ${diff.mappings.length} regional ${routeNoun}.`; }; -const ruleCountLabel = (count: number): string => - `${count} ${count === 1 ? "rule" : "rules"}`; +const isMappingWarning = (warning: ControlDDiff["warnings"][number]): boolean => + warning.code === "missing-country" || + warning.code === "approximate-location" || + warning.code === "skipped-location"; + +const reviewDescription = (mappingCount: number, ruleCount: number): string => { + if (mappingCount > 0 && ruleCount > 0) { + return "Review route approximations and source-rule behavior before synchronizing."; + } + if (mappingCount > 0) { + return "Review the suggested Control D exits before synchronizing."; + } + return "Some source rules are interpreted differently by Control D. Review them before synchronizing."; +}; export const ControlDFeatureToggle = ({ onEnabledChange, @@ -123,13 +117,8 @@ export const ControlDFeatureToggle = ({ return ( - Control D integration - Beta / local - - } - description="Show a separate Control D section for one-way regional DNS synchronization." + title="Control D integration" + description="Enable the separate Control D section for one-way regional DNS synchronization. Available in beta and local builds." focusControlOnTitleClick action={ ( -
- {[ - ["Folders to create", diff.createFolders], - ["Rules to add", diff.addRules], - ["Rules to update", diff.updateRules], - ["Unchanged rules", diff.unchangedRules], - ].map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
-); - const ResolverSetup = ({ resolver }: { resolver: string }) => { const [copied, setCopied] = useState(false); const recordAction = ( @@ -201,12 +174,7 @@ const ResolverSetup = ({ resolver }: { resolver: string }) => { return ( -

Browser DNS

- Manual setup - - } + title={

Browser DNS

} description="Secure DNS remains a browser setting. Privacy Thing cannot change it automatically." >
@@ -262,6 +230,8 @@ export const ControlDPanel = () => { const [confirmApproximate, setConfirmApproximate] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); + const [editingLocationId, setEditingLocationId] = useState(null); + const [warningsExpanded, setWarningsExpanded] = useState(false); const run = useCallback(async (message: unknown) => { setBusy(true); @@ -292,20 +262,22 @@ export const ControlDPanel = () => { () => new Map(proxies.map((proxy) => [proxy.pk, proxy])), [proxies], ); - const presentation = statusPresentation(state); + const presentation = statusLabel(state); const visibleError = notice ?? state?.lastError ?? null; const hasAppliedSync = Boolean(state?.lastSuccessAt); - let syncModeLabel = "Manual"; let syncDescription = "Preview and apply the first synchronization to activate automatic updates."; if (state?.autoSyncEnabled) { - syncModeLabel = "Automatic"; syncDescription = `Automatic sync is active · Last successful sync: ${formatTime(state.lastSuccessAt)}`; } else if (hasAppliedSync) { - syncModeLabel = "Paused"; syncDescription = `Automatic sync is paused · Last successful sync: ${formatTime(state?.lastSuccessAt ?? null)}`; } + const mappingWarnings = diff?.warnings.filter(isMappingWarning) ?? []; + const ruleWarnings = + diff?.warnings.filter((warning) => !isMappingWarning(warning)) ?? []; + const previewDescription = diff ? previewSummary(diff) : null; + const connect = async () => { if (!(await requestApiAccess())) { setNotice("Access to the Control D API was not granted."); @@ -332,19 +304,21 @@ export const ControlDPanel = () => { type: CONTROL_D_COMMANDS.updateMapping, mapping: next, }); - if (response?.ok) await run({ type: CONTROL_D_COMMANDS.preview }); + if (response?.ok) { + setEditingLocationId(null); + await run({ type: CONTROL_D_COMMANDS.preview }); + } }; return ( -

Control D

- Experimental - - } + title={

Control D

} description="Publish compatible regional rules to an isolated Control D profile. Synchronization is one-way." - headerActions={{presentation.label}} + headerActions={ +

+ {presentation} +

+ } data-control-d-state={state?.status ?? "loading"} > {!state?.connected ? ( @@ -373,14 +347,7 @@ export const ControlDPanel = () => { ) : ( <> -

Synchronization

- - {syncModeLabel} - - - } + title={

Synchronization

} description={syncDescription} >
@@ -418,6 +385,9 @@ export const ControlDPanel = () => {

Last attempt: {formatTime(state.lastAttemptAt)}

+ {previewDescription ? ( +

{previewDescription}

+ ) : null} {visibleError ? ( @@ -429,70 +399,34 @@ export const ControlDPanel = () => {
) : null} - {diff ? ( - Pending changes} - description="This preview is read-only. Review all routes before applying." - > - - - ) : null} - {diff?.mappings.length ? ( Regional routes} - description="Each Privacy Thing location maps to one Control D exit." + description="Privacy Thing automatically chooses the nearest suitable Control D exit. Change a route only when you want an override." > -
- - - - Privacy Thing profile - Control D exit - - - - {diff.mappings.map((mapping) => ( - - -
- {mapping.locationLabel ?? mapping.locationId} -
-
- - {ruleCountLabel(mapping.ruleCount ?? 0)} - - - {mapping.status} - -
-
- - - -
- ))} -
-
+
+ {diff.mappings.map((mapping) => { + const selectedProxy = mapping.proxyPk + ? proxyByPk.get(mapping.proxyPk) + : undefined; + const isEditing = editingLocationId === mapping.locationId; + return ( + + setEditingLocationId(editing ? mapping.locationId : null) + } + onMappingChange={(nextMapping, proxyPk) => + void updateMapping(nextMapping, proxyPk) + } + /> + ); + })}
) : null} @@ -500,22 +434,61 @@ export const ControlDPanel = () => { {diff?.warnings.length ? ( -

Review required

- {diff.warnings.length} - +

+ {diff.warnings.length}{" "} + {diff.warnings.length === 1 ? "item needs" : "items need"} review +

} - description="Some rules or locations could not be mapped exactly." + description={reviewDescription( + mappingWarnings.length, + ruleWarnings.length, + )} > -
    - {diff.warnings.map((warning, index) => ( -
  • + + {mappingWarnings.length > 0 ? ( +
  • - ))} -
+ Review routes + + ) : null} + {ruleWarnings.length > 0 ? ( + + ) : null} +
+ {warningsExpanded ? ( +
    + {diff.warnings.map((warning, index) => ( +
  • + {warning.message} +
  • + ))} +
+ ) : null}
) : null} diff --git a/src/experimental/control-d/ui-regional-route.tsx b/src/experimental/control-d/ui-regional-route.tsx new file mode 100644 index 0000000..b99d48d --- /dev/null +++ b/src/experimental/control-d/ui-regional-route.tsx @@ -0,0 +1,111 @@ +import type { ControlDMapping, ControlDProxyLocation } from "./contracts"; + +import { Button } from "@/ui/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/ui/components/ui/select"; + +const ruleCountLabel = (count: number): string => + `${count} ${count === 1 ? "rule" : "rules"}`; + +const proxyLabel = ( + mapping: ControlDMapping, + proxy: ControlDProxyLocation | undefined, +): string => { + if (proxy) return `${proxy.city}, ${proxy.countryName}`; + if (mapping.status === "skipped") return "Not synchronized"; + return "Control D exit unavailable"; +}; + +const mappingDescription = (status: ControlDMapping["status"]): string => { + if (status === "exact") { + return "Automatically matched to the nearest exit in the same country."; + } + if (status === "approximate") { + return "Nearest available exit. Review this route before applying."; + } + return "This regional profile is excluded from synchronization."; +}; + +type RouteProps = { + busy: boolean; + editing: boolean; + mapping: ControlDMapping; + proxies: readonly ControlDProxyLocation[]; + proxy: ControlDProxyLocation | undefined; + onEditingChange: (editing: boolean) => void; + onMappingChange: (mapping: ControlDMapping, proxyPk: string) => void; +}; + +export const ControlDRegionalRoute = ({ + busy, + editing, + mapping, + proxies, + proxy, + onEditingChange, + onMappingChange, +}: RouteProps) => { + const label = mapping.locationLabel ?? mapping.locationId; + const selectedProxyLabel = proxyLabel(mapping, proxy); + let actionLabel = "Change"; + if (editing) actionLabel = "Cancel"; + else if (mapping.status === "skipped" || !proxy) actionLabel = "Choose exit"; + + return ( +
+
+
+
{label}
+
+ {ruleCountLabel(mapping.ruleCount ?? 0)} +
+
+ {editing ? ( + + ) : ( +
+
{selectedProxyLabel}
+
+ {mappingDescription(mapping.status)} +
+
+ )} +
+ +
+ ); +}; diff --git a/src/ui/options/stories/ControlD.stories.tsx b/src/ui/options/stories/ControlD.stories.tsx index 4b152c9..e14738b 100644 --- a/src/ui/options/stories/ControlD.stories.tsx +++ b/src/ui/options/stories/ControlD.stories.tsx @@ -10,6 +10,7 @@ import { ControlDPanel } from "../../../experimental/control-d/ui-entry"; import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; import { DEFAULT_PREFERENCES } from "@/shared/settings-defaults"; +import type { ThemeMode } from "@/shared/types"; import { ThemeProvider } from "@/ui/shared/ThemeProvider"; const baseState: ControlDPublicState = { @@ -31,45 +32,64 @@ const baseState: ControlDPublicState = { const diff: ControlDDiff = { createProfile: false, createEndpoint: false, - createFolders: 2, - addRules: 5, + createFolders: 0, + addRules: 0, updateRules: 0, deleteRules: 0, - unchangedRules: 3, + unchangedRules: 6, warnings: [ { - code: "approximate-location", - message: "Ottawa uses the nearest available Control D exit in Montreal.", - locationId: "ottawa", + code: "exact-pattern-broadened", + message: + "www.linkedin.com is exact in Privacy Thing but would include subdomains in Control D.", + pattern: "www.linkedin.com", + }, + { + code: "exact-pattern-broadened", + message: + "github.com is exact in Privacy Thing but would include subdomains in Control D.", + pattern: "github.com", + }, + { + code: "exact-pattern-broadened", + message: + "iteracja.elpassion.com is exact in Privacy Thing but would include subdomains in Control D.", + pattern: "iteracja.elpassion.com", + }, + { + code: "exact-pattern-broadened", + message: + "test.pl is exact in Privacy Thing but would include subdomains in Control D.", + pattern: "test.pl", }, ], mappings: [ { locationId: "warsaw", locationLabel: "Warsaw", - ruleCount: 3, + ruleCount: 4, proxyPk: "WAW", status: "exact", confirmed: true, }, { - locationId: "paris", - locationLabel: "Paris", - ruleCount: 2, - proxyPk: "PAR", + locationId: "ottawa", + locationLabel: "Ottawa", + ruleCount: 1, + proxyPk: "YOW", status: "exact", confirmed: true, }, { - locationId: "ottawa", - locationLabel: "Ottawa", + locationId: "paris", + locationLabel: "Paris", ruleCount: 1, - proxyPk: "YUL", - status: "approximate", - confirmed: false, + proxyPk: "PAR", + status: "exact", + confirmed: true, }, ], - requiresApproximationConfirmation: true, + requiresApproximationConfirmation: false, }; const proxies = [ @@ -90,21 +110,25 @@ const proxies = [ longitude: 2.35, }, { - pk: "YUL", - city: "Montreal", + pk: "YOW", + city: "Ottawa", countryCode: "CA", countryName: "Canada", - latitude: 45.5, - longitude: -73.57, + latitude: 45.42, + longitude: -75.7, }, ]; -const installBoundary = (state: ControlDPublicState): void => { +const installBoundary = ( + state: ControlDPublicState, + themeMode: ThemeMode = "light", +): void => { Reflect.set(globalThis, "chrome", { runtime: { id: "storybook-control-d", sendMessage: async (message: { type?: string }) => { if (message.type === CONTROL_D_COMMANDS.preview) { + await Promise.resolve(); return { ok: true, state, diff, proxies }; } return { ok: true, state }; @@ -120,7 +144,10 @@ const installBoundary = (state: ControlDPublicState): void => { storage: { local: { get: async () => ({ - [EXTENSION_STORAGE_KEYS.preferences]: DEFAULT_PREFERENCES, + [EXTENSION_STORAGE_KEYS.preferences]: { + ...DEFAULT_PREFERENCES, + themeMode, + }, }), set: async () => undefined, remove: async () => undefined, @@ -134,8 +161,14 @@ const installBoundary = (state: ControlDPublicState): void => { }); }; -const Surface = ({ state }: { state: ControlDPublicState }) => { - installBoundary(state); +const Surface = ({ + state, + themeMode, +}: { + state: ControlDPublicState; + themeMode?: ThemeMode; +}) => { + installBoundary(state, themeMode); return (
@@ -182,6 +215,25 @@ export const Ready: Story = { await canvas.findByRole("button", { name: "Preview changes" }), ); await expect(await canvas.findByText("Regional routes")).toBeVisible(); + await expect(canvas.getByText("Warsaw, Poland")).toBeVisible(); + await expect(canvas.getByText("Ottawa, Canada")).toBeVisible(); + await expect(canvas.getByText("Paris, France")).toBeVisible(); + await expect(canvas.queryAllByRole("combobox")).toHaveLength(0); + await expect(canvas.queryByText("Folders to create")).not.toBeInTheDocument(); + await expect(canvas.getByText("Everything is up to date.")).toBeVisible(); + + await userEvent.click(canvas.getAllByRole("button", { name: "Change" })[0]!); + await expect( + canvas.getByRole("combobox", { name: "Choose Control D exit for Warsaw" }), + ).toHaveTextContent("Warsaw, Poland"); + + await userEvent.click(canvas.getByRole("button", { name: "Review details" })); + await expect(canvas.getByText(/www\.linkedin\.com is exact/)).toBeVisible(); + await expect( + canvas.getByRole("link", { name: "Review source rules" }), + ).toHaveAttribute("href", "#page-rules"); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await userEvent.click(canvas.getByRole("button", { name: "Hide details" })); }, }; @@ -210,3 +262,14 @@ export const Conflict: Story = { export const Syncing: Story = { render: () => , }; + +export const DarkReady: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Preview changes" }), + ); + await expect(await canvas.findByText("Warsaw, Poland")).toBeVisible(); + }, +}; diff --git a/tests/e2e/extension-options-navigation.spec.ts b/tests/e2e/extension-options-navigation.spec.ts index 109ead6..7b4f46b 100644 --- a/tests/e2e/extension-options-navigation.spec.ts +++ b/tests/e2e/extension-options-navigation.spec.ts @@ -54,9 +54,7 @@ test("loads the options page from the extension", async ({ context, extensionId "type", "password", ); - await expect( - page.locator("[data-control-d-state]").getByText("Experimental", { exact: true }), - ).toBeVisible(); + await expect(page.locator("[data-control-d-state]")).toContainText("Not connected"); } await openSettingsTab(page, "about"); await expect(page.locator("#about-version")).toHaveText(/^\d+\.\d+/); From 47bf026baaaf8e2a7528e7807bca6f1f773318ae Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:27:43 +0200 Subject: [PATCH 05/11] fix(control-d): stop rewriting hostname patterns Suffix rules must stay *host in Control D instead of being collapsed to the apex during sync. Co-authored-by: Cursor --- src/experimental/control-d/compiler.test.ts | 28 ++++++++-------- src/experimental/control-d/compiler.ts | 37 +++++---------------- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/src/experimental/control-d/compiler.test.ts b/src/experimental/control-d/compiler.test.ts index 4a02883..f8a8c5f 100644 --- a/src/experimental/control-d/compiler.test.ts +++ b/src/experimental/control-d/compiler.test.ts @@ -90,18 +90,23 @@ describe("compileControlDPattern", () => { }); }); - it("maps Privacy Thing suffix patterns to apex-and-subdomains", () => { + it("preserves Privacy Thing suffix patterns", () => { expect(compileControlDPattern("*example.com")).toEqual({ - hostname: "example.com", + hostname: "*example.com", }); }); - it("includes exact hosts with a widening warning and rejects unproven wildcards", () => { - expect(compileControlDPattern("example.com")).toMatchObject({ + it("preserves exact hosts and wildcards supported by Control D", () => { + expect(compileControlDPattern("example.com")).toEqual({ hostname: "example.com", - warning: { code: "exact-pattern-broadened" }, }); - expect(compileControlDPattern("server-*.example.com")).toMatchObject({ + expect(compileControlDPattern("server-*.example.com")).toEqual({ + hostname: "server-*.example.com", + }); + }); + + it("rejects values that are not hostname patterns", () => { + expect(compileControlDPattern("https://example.com/path")).toMatchObject({ warning: { code: "unsupported-pattern" }, }); }); @@ -122,10 +127,7 @@ describe("compileControlDPattern", () => { locationLabel: "Warsaw", ruleCount: 2, }); - expect(result.warnings).toHaveLength(2); - expect( - result.warnings.every((warning) => warning.code === "exact-pattern-broadened"), - ).toBe(true); + expect(result.warnings).toEqual([]); }); }); @@ -148,11 +150,11 @@ describe("compileControlDState", () => { expect(result.rules).toHaveLength(6); expect(result.rules.map((entry) => entry.hostname)).toEqual([ - "example.com", + "*example.com", + "*www.instagram.com", "github.com", "iteracja.elpassion.com", "test.pl", - "www.instagram.com", "www.linkedin.com", ]); expect(result.mappings).toMatchObject({ @@ -173,7 +175,7 @@ describe("compileControlDState", () => { expect(result.rules).toEqual([ { sourcePattern: "*example.com", - hostname: "example.com", + hostname: "*example.com", locationId: "warsaw", proxyPk: "WAW", }, diff --git a/src/experimental/control-d/compiler.ts b/src/experimental/control-d/compiler.ts index c8a3ef9..bb48ffd 100644 --- a/src/experimental/control-d/compiler.ts +++ b/src/experimental/control-d/compiler.ts @@ -7,7 +7,6 @@ import type { ControlDProxyLocation, } from "./contracts"; -import { getDomainPatternKind } from "@/shared/domain-match"; import type { DomainRule, Location } from "@/shared/types"; export type ControlDCompilation = { @@ -155,36 +154,20 @@ const resolveMapping = ( export const compileControlDPattern = ( pattern: string, -): - | { hostname: string; warning?: ControlDCompileWarning } - | { warning: ControlDCompileWarning } => { - const normalized = pattern.trim().toLowerCase().replace(/\.$/, ""); - const kind = getDomainPatternKind(normalized); +): { hostname: string } | { warning: ControlDCompileWarning } => { + const isHostnamePattern = + pattern.length > 0 && + pattern === pattern.trim() && + pattern !== "*" && + /^[a-z0-9*._-]+$/i.test(pattern); - if (kind === "subdomains-only" && /^\*\.[a-z0-9.-]+$/.test(normalized)) { - return { hostname: normalized }; - } - - if (kind === "apex-and-subdomains" && /^\*[a-z0-9.-]+$/.test(normalized)) { - return { hostname: normalized.slice(1) }; - } - - if (kind === "exact") { - return { - hostname: normalized, - warning: { - code: "exact-pattern-broadened", - pattern, - message: `${pattern} is exact in Privacy Thing but would include subdomains in Control D.`, - }, - }; - } + if (isHostnamePattern) return { hostname: pattern }; return { warning: { code: "unsupported-pattern", pattern, - message: `${pattern} has wildcard semantics that cannot be proven equivalent in Control D.`, + message: `${pattern} is not a valid Control D hostname pattern.`, }, }; }; @@ -233,10 +216,6 @@ export const compileControlDState = ({ warnings.push({ ...patternResult.warning, locationId: location.id }); continue; } - if (patternResult.warning) { - warnings.push({ ...patternResult.warning, locationId: location.id }); - } - const previousLocation = usedHostnames.get(patternResult.hostname); if (previousLocation && previousLocation !== location.id) { warnings.push({ From 6f15b7b0b977f227e00c52cc56e784dd0a655f65 Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:28:00 +0200 Subject: [PATCH 06/11] fix(control-d): hide auto exits until fallback Keep preview and apply on one regional snapshot, debounce saved rule and location changes, and match Advanced chrome. Co-authored-by: Cursor --- CHANGELOG.md | 5 +- .../control-d/background-entry.target.test.ts | 146 +++++- .../control-d/background-entry.ts | 21 +- src/experimental/control-d/contracts.ts | 7 +- src/experimental/control-d/storage.ts | 1 - src/experimental/control-d/ui-entry.tsx | 493 +++++++++--------- .../control-d/ui-regional-route.tsx | 18 +- src/ui/options/stories/ControlD.stories.tsx | 163 ++++-- 8 files changed, 522 insertions(+), 332 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f3ac2..1223136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ The format is based on Keep a Changelog and the project follows Semantic Version - Keep Control D synchronization attached to resources by their saved IDs when the API normalizes display names, restore automatic sync after the obsolete - name conflict, expose preview failures in debug logs, and show automatically - matched regional exits explicitly with actionable review and override controls. + name conflict, and keep preview and sync on one prepared regional-route + snapshot without rewriting Control D hostname patterns. Hide automatic route + matches until a fallback needs confirmation or the user opens overrides. - Confirm installed Battery protection before a page first queries the API, and clear stale integrity evidence after the protection recovers. diff --git a/src/experimental/control-d/background-entry.target.test.ts b/src/experimental/control-d/background-entry.target.test.ts index aae6c21..c5696e6 100644 --- a/src/experimental/control-d/background-entry.target.test.ts +++ b/src/experimental/control-d/background-entry.target.test.ts @@ -1,23 +1,51 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerControlD } from "./background-entry"; -import { CONTROL_D_COMMANDS } from "./contracts"; +import { + CONTROL_D_COMMANDS, + type ControlDConfig, + type ControlDPreparedSnapshot, +} from "./contracts"; +import { applyControlDSync, prepareControlDSync } from "./reconcile"; import { CONTROL_D_STORE_KEYS } from "./storage"; import { logExtensionEvent } from "@/background/logger"; +import { LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; +import { RULES_STORAGE_KEY } from "@/background/storage/rules"; vi.mock("@/background/logger", () => ({ logExtensionEvent: vi.fn(), })); +type ReconcileModule = { + applyControlDSync: typeof applyControlDSync; + prepareControlDSync: typeof prepareControlDSync; + [key: string]: unknown; +}; + +vi.mock("./reconcile", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + applyControlDSync: vi.fn(actual.applyControlDSync), + prepareControlDSync: vi.fn(actual.prepareControlDSync), + }; +}); + type MessageListener = ( message: unknown, sender: { id?: string }, sendResponse: (response: unknown) => void, ) => boolean; +type StorageListener = ( + changes: Record, + areaName: string, +) => void; + const storageState: Record = {}; let messageListener: MessageListener; +let storageListener: StorageListener; beforeEach(() => { vi.clearAllMocks(); @@ -43,12 +71,101 @@ beforeEach(() => { }), remove: vi.fn(async (key: string) => Reflect.deleteProperty(storageState, key)), }, - onChanged: { addListener: vi.fn() }, + onChanged: { + addListener: vi.fn((listener: StorageListener) => { + storageListener = listener; + }), + }, }, }); }); describe("Control D background entry", () => { + it("returns the same prepared snapshot after preview and synchronization", async () => { + const config: ControlDConfig = { + version: 1, + instanceId: "existing-instance", + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + const prepared: Awaited> = { + compilation: { rules: [], warnings: [], mappings: {} }, + proxies: [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.23, + longitude: 21.01, + }, + ], + diff: { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 1, + warnings: [], + mappings: [ + { + locationId: "warsaw", + locationLabel: "Warsaw", + ruleCount: 1, + proxyPk: "WAW", + status: "exact", + confirmed: true, + }, + ], + requiresApproximationConfirmation: false, + }, + }; + const expectedSnapshot: ControlDPreparedSnapshot = { + diff: prepared.diff, + proxies: prepared.proxies, + }; + storageState[CONTROL_D_STORE_KEYS[0]] = config; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + vi.mocked(prepareControlDSync) + .mockResolvedValueOnce(prepared) + .mockResolvedValueOnce(prepared); + vi.mocked(applyControlDSync).mockResolvedValueOnce(config); + registerControlD({ getDebugMode: async () => false }); + + const request = (type: string) => + new Promise>((resolve) => { + messageListener({ type }, { id: "extension-id" }, (response) => + resolve(response as Record), + ); + }); + + const previewResponse = await request(CONTROL_D_COMMANDS.preview); + expect(previewResponse).toMatchObject({ ok: true, snapshot: expectedSnapshot }); + expect(previewResponse).not.toHaveProperty("diff"); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }); + + const syncResponse = await request(CONTROL_D_COMMANDS.syncNow); + + expect(syncResponse).toMatchObject({ ok: true, snapshot: expectedSnapshot }); + expect(syncResponse).not.toHaveProperty("diff"); + }); + it("persists a toggle action in extension logs when debug mode comes from storage", async () => { registerControlD({ getDebugMode: async () => true }); @@ -137,4 +254,29 @@ describe("Control D background entry", () => { }); timeoutSpy.mockRestore(); }); + + it("debounces every saved rule or location mutation into automatic reconciliation", () => { + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + registerControlD({ getDebugMode: async () => false }); + + const ruleSnapshots = [ + [{ pattern: "added.example", enabled: true }], + [{ pattern: "edited.example", enabled: true }], + [], + ]; + for (const rules of ruleSnapshots) { + storageListener({ [RULES_STORAGE_KEY]: { newValue: rules } }, "local"); + } + storageListener( + { [LOCATIONS_STORAGE_KEY]: { newValue: [{ id: "warsaw" }] } }, + "local", + ); + + expect(timeoutSpy).toHaveBeenCalledTimes(4); + expect(timeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1_500); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(3); + timeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + }); }); diff --git a/src/experimental/control-d/background-entry.ts b/src/experimental/control-d/background-entry.ts index 148ac3e..4983112 100644 --- a/src/experimental/control-d/background-entry.ts +++ b/src/experimental/control-d/background-entry.ts @@ -6,6 +6,7 @@ import { type ControlDCommand, type ControlDConfig, type ControlDMapping, + type ControlDPreparedSnapshot, } from "./contracts"; import { applyControlDSync, @@ -44,6 +45,11 @@ type SyncResult = | { ok: false; failed: ControlDConfig; error: unknown } | null; +const toPreparedSnapshot = ({ + diff, + proxies, +}: ControlDPreparedSync): ControlDPreparedSnapshot => ({ diff, proxies }); + const log = ( deps: BackgroundEntryDeps, event: string, @@ -364,14 +370,6 @@ const createController = (deps: BackgroundEntryDeps) => { if (command.type === CONTROL_D_COMMANDS.preview) { try { const prepared = await prepareControlDSync(createClient(apiKey), config); - const ready: ControlDConfig = { - ...config, - autoSyncEnabled: config.lastSyncedHash ? true : config.autoSyncEnabled, - status: "ready", - lastAttemptAt: new Date().toISOString(), - lastError: null, - }; - await saveControlDConfig(ready); log( deps, "control-d.diff.ready", @@ -386,9 +384,8 @@ const createController = (deps: BackgroundEntryDeps) => { ); return { ok: true, - state: await toControlDPublicState(ready), - diff: prepared.diff, - proxies: prepared.proxies, + state: await toControlDPublicState(config), + snapshot: toPreparedSnapshot(prepared), }; } catch (error) { const failed = await saveFailure(config, error); @@ -436,7 +433,7 @@ const createController = (deps: BackgroundEntryDeps) => { return { ok: true, state: await toControlDPublicState(result.next), - diff: result.prepared.diff, + snapshot: toPreparedSnapshot(result.prepared), }; }; diff --git a/src/experimental/control-d/contracts.ts b/src/experimental/control-d/contracts.ts index c997207..c04e3a0 100644 --- a/src/experimental/control-d/contracts.ts +++ b/src/experimental/control-d/contracts.ts @@ -71,7 +71,6 @@ export type ControlDCompiledRule = { export type ControlDCompileWarning = { code: - | "exact-pattern-broadened" | "unsupported-pattern" | "missing-location" | "missing-country" @@ -95,6 +94,11 @@ export type ControlDDiff = { requiresApproximationConfirmation: boolean; }; +export type ControlDPreparedSnapshot = { + diff: ControlDDiff; + proxies: ControlDProxyLocation[]; +}; + export type ControlDPublicState = { enabled: boolean; connected: boolean; @@ -105,7 +109,6 @@ export type ControlDPublicState = { endpointId: string | null; hasResolver: boolean; resolverDoh: string | null; - locationMappings: ControlDMapping[]; lastAttemptAt: string | null; lastSuccessAt: string | null; lastError: string | null; diff --git a/src/experimental/control-d/storage.ts b/src/experimental/control-d/storage.ts index e63aa42..8d77a2e 100644 --- a/src/experimental/control-d/storage.ts +++ b/src/experimental/control-d/storage.ts @@ -108,7 +108,6 @@ export const toControlDPublicState = async ( endpointId: config.endpointId, hasResolver: config.resolverDoh !== null, resolverDoh: config.resolverDoh, - locationMappings: Object.values(config.locationMappings), lastAttemptAt: config.lastAttemptAt, lastSuccessAt: config.lastSuccessAt, lastError: config.lastError, diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx index cbfc762..0781aac 100644 --- a/src/experimental/control-d/ui-entry.tsx +++ b/src/experimental/control-d/ui-entry.tsx @@ -7,30 +7,29 @@ import { CONTROL_D_STATUS_URL, type ControlDDiff, type ControlDMapping, - type ControlDProxyLocation, + type ControlDPreparedSnapshot, type ControlDPublicState, } from "./contracts"; import { ControlDRegionalRoute } from "./ui-regional-route"; import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; -import { SettingsSectionCard } from "@/ui/components/SettingsSectionCard"; -import { SettingsSubcard } from "@/ui/components/SettingsSubcard"; import { Button } from "@/ui/components/ui/button"; +import { Card, CardContent } from "@/ui/components/ui/card"; import { Checkbox } from "@/ui/components/ui/checkbox"; import { Input } from "@/ui/components/ui/input"; import { Separator } from "@/ui/components/ui/separator"; import { Switch } from "@/ui/components/ui/switch"; -import { PAGE_ANCHORS } from "@/ui/options/navigation"; type UiResponse = | { ok: true; state: ControlDPublicState; - diff?: ControlDDiff; - proxies?: ControlDProxyLocation[]; + snapshot?: ControlDPreparedSnapshot; } | { ok: false; error: string; state?: ControlDPublicState }; +const EMPTY_PROXIES: ControlDPreparedSnapshot["proxies"] = []; + const send = async (message: unknown): Promise => (await chrome.runtime.sendMessage(message)) as UiResponse; @@ -64,28 +63,14 @@ const statusLabel = (state: ControlDPublicState | null): string => { const previewSummary = (diff: ControlDDiff): string => { const changedRuleCount = diff.addRules + diff.updateRules + diff.deleteRules; if (changedRuleCount === 0 && !diff.createProfile && !diff.createEndpoint) { - return "Everything is up to date."; + return "Control D is up to date."; } - - const ruleVerb = changedRuleCount === 1 ? "rule is" : "rules are"; - const routeNoun = diff.mappings.length === 1 ? "route" : "routes"; - return `${changedRuleCount} ${ruleVerb} ready to synchronize across ${diff.mappings.length} regional ${routeNoun}.`; + return "Changes are ready to synchronize."; }; -const isMappingWarning = (warning: ControlDDiff["warnings"][number]): boolean => - warning.code === "missing-country" || - warning.code === "approximate-location" || - warning.code === "skipped-location"; - -const reviewDescription = (mappingCount: number, ruleCount: number): string => { - if (mappingCount > 0 && ruleCount > 0) { - return "Review route approximations and source-rule behavior before synchronizing."; - } - if (mappingCount > 0) { - return "Review the suggested Control D exits before synchronizing."; - } - return "Some source rules are interpreted differently by Control D. Review them before synchronizing."; -}; +const settingTitle = (text: string) => ( +

{text}

+); export const ControlDFeatureToggle = ({ onEnabledChange, @@ -117,7 +102,7 @@ export const ControlDFeatureToggle = ({ return ( { }; return ( - Browser DNS} +
@@ -217,7 +202,7 @@ const ResolverSetup = ({ resolver }: { resolver: string }) => {

Disconnecting the integration does not revert your browser DNS setting.

- + ); }; @@ -225,13 +210,12 @@ const ResolverSetup = ({ resolver }: { resolver: string }) => { export const ControlDPanel = () => { const [state, setState] = useState(null); const [apiKey, setApiKey] = useState(""); - const [diff, setDiff] = useState(null); - const [proxies, setProxies] = useState([]); + const [snapshot, setSnapshot] = useState(null); const [confirmApproximate, setConfirmApproximate] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); const [editingLocationId, setEditingLocationId] = useState(null); - const [warningsExpanded, setWarningsExpanded] = useState(false); + const [showRouteOverrides, setShowRouteOverrides] = useState(false); const run = useCallback(async (message: unknown) => { setBusy(true); @@ -243,8 +227,7 @@ export const ControlDPanel = () => { setNotice(response.error); return response; } - if (response.diff) setDiff(response.diff); - if (response.proxies) setProxies(response.proxies); + if (response.snapshot) setSnapshot(response.snapshot); return response; } catch (error) { setNotice(error instanceof Error ? error.message : "Control D request failed."); @@ -255,9 +238,15 @@ export const ControlDPanel = () => { }, []); useEffect(() => { - void run({ type: CONTROL_D_COMMANDS.getState }); + void (async () => { + const response = await run({ type: CONTROL_D_COMMANDS.getState }); + if (response?.ok && response.state.connected && !response.state.autoSyncEnabled) { + await run({ type: CONTROL_D_COMMANDS.preview }); + } + })(); }, [run]); + const proxies = snapshot?.proxies ?? EMPTY_PROXIES; const proxyByPk = useMemo( () => new Map(proxies.map((proxy) => [proxy.pk, proxy])), [proxies], @@ -266,17 +255,28 @@ export const ControlDPanel = () => { const visibleError = notice ?? state?.lastError ?? null; const hasAppliedSync = Boolean(state?.lastSuccessAt); let syncDescription = - "Preview and apply the first synchronization to activate automatic updates."; + "The first write is explicit. Later rule and location changes synchronize automatically after they are saved."; if (state?.autoSyncEnabled) { - syncDescription = `Automatic sync is active · Last successful sync: ${formatTime(state.lastSuccessAt)}`; + syncDescription = `Rule and location changes synchronize automatically after saving. Last successful sync: ${formatTime(state.lastSuccessAt)}.`; } else if (hasAppliedSync) { syncDescription = `Automatic sync is paused · Last successful sync: ${formatTime(state?.lastSuccessAt ?? null)}`; } - const mappingWarnings = diff?.warnings.filter(isMappingWarning) ?? []; - const ruleWarnings = - diff?.warnings.filter((warning) => !isMappingWarning(warning)) ?? []; + const diff = snapshot?.diff ?? null; + const blockingWarnings = + diff?.warnings.filter( + (warning) => + warning.code === "unsupported-pattern" || warning.code === "missing-location", + ) ?? []; const previewDescription = diff ? previewSummary(diff) : null; + const approximateMappings = + diff?.mappings.filter((mapping) => mapping.status === "approximate") ?? []; + const visibleMappings = showRouteOverrides + ? (diff?.mappings ?? []) + : approximateMappings; + const showMappingControls = + visibleMappings.length > 0 && + (showRouteOverrides || Boolean(diff?.requiresApproximationConfirmation)); const connect = async () => { if (!(await requestApiAccess())) { @@ -287,6 +287,7 @@ export const ControlDPanel = () => { if (response?.ok) { setApiKey(""); setNotice("API key verified. It is stored only on this device."); + await run({ type: CONTROL_D_COMMANDS.preview }); } }; @@ -310,177 +311,109 @@ export const ControlDPanel = () => { } }; + const revealRouteOverrides = () => { + setEditingLocationId(null); + if (showRouteOverrides) { + setShowRouteOverrides(false); + return; + } + void (async () => { + if (!snapshot) await run({ type: CONTROL_D_COMMANDS.preview }); + setShowRouteOverrides(true); + })(); + }; + + let syncAction: React.ReactNode; + if (state?.status === "conflict") { + syncAction = ( + + ); + } else if (state?.autoSyncEnabled) { + syncAction = ( + + ); + } + return ( - Control D} - description="Publish compatible regional rules to an isolated Control D profile. Synchronization is one-way." - headerActions={ -

- {presentation} -

- } - data-control-d-state={state?.status ?? "loading"} - > - {!state?.connected ? ( - Connect your account} - description="Use a write-enabled API key. It stays in local extension storage and is excluded from export and browser sync." - > -
- setApiKey(event.target.value)} - /> - + + +
+
+

Control D

+

+ Publish compatible regional rules to an isolated Control D profile. + Synchronization is one-way. +

- - ) : ( - <> - Synchronization} - description={syncDescription} +

+ {presentation} +

+
+ {!state?.connected ? ( + -
+
+ setApiKey(event.target.value)} + /> - {state.autoSyncEnabled ? ( - - ) : null} - {state.status === "conflict" ? ( - - ) : null} -
-

- Last attempt: {formatTime(state.lastAttemptAt)} -

- {previewDescription ? ( -

{previewDescription}

- ) : null} - - - {visibleError ? ( -
- {visibleError}
- ) : null} - - {diff?.mappings.length ? ( - Regional routes} - description="Privacy Thing automatically chooses the nearest suitable Control D exit. Change a route only when you want an override." - > -
- {diff.mappings.map((mapping) => { - const selectedProxy = mapping.proxyPk - ? proxyByPk.get(mapping.proxyPk) - : undefined; - const isEditing = editingLocationId === mapping.locationId; - return ( - - setEditingLocationId(editing ? mapping.locationId : null) - } - onMappingChange={(nextMapping, proxyPk) => - void updateMapping(nextMapping, proxyPk) - } - /> - ); - })} -
-
- ) : null} - - {diff?.warnings.length ? ( - - {diff.warnings.length}{" "} - {diff.warnings.length === 1 ? "item needs" : "items need"} review - - } - description={reviewDescription( - mappingWarnings.length, - ruleWarnings.length, - )} + + ) : ( + <> +
- {mappingWarnings.length > 0 ? ( - - ) : null} - {ruleWarnings.length > 0 ? ( - - ) : null}
- {warningsExpanded ? ( -
    - {diff.warnings.map((warning, index) => ( + {previewDescription ? ( +

    {previewDescription}

    + ) : null} + {blockingWarnings.length > 0 ? ( +
      + {blockingWarnings.map((warning, index) => (
    • @@ -489,74 +422,118 @@ export const ControlDPanel = () => { ))}
    ) : null} - - ) : null} - - {diff && !state.autoSyncEnabled ? ( - Apply first synchronization - } - description="The first write is always explicit. Later valid settings changes synchronize automatically." - > - {diff.requiresApproximationConfirmation ? ( - + {diff && !state.autoSyncEnabled ? ( +
    + {diff.requiresApproximationConfirmation ? ( + + ) : null} + +
    ) : null} - -
    - ) : null} +
    + {visibleMappings.map((mapping) => { + const selectedProxy = mapping.proxyPk + ? proxyByPk.get(mapping.proxyPk) + : undefined; + const isEditing = editingLocationId === mapping.locationId; + return ( + + setEditingLocationId(editing ? mapping.locationId : null) + } + onMappingChange={(nextMapping, proxyPk) => + void updateMapping(nextMapping, proxyPk) + } + /> + ); + })} +
    + + ) : null} - {state.resolverDoh ? : null} + {state.resolverDoh ? : null} - - Connection} - description="Disconnecting forgets the API key and stops synchronization. Remote resources and browser DNS remain unchanged." + + +
    + +
    +
    + + )} + + {!state?.connected && visibleError ? ( +
    -
    - -
    - - - )} - - {!state?.connected && visibleError ? ( -
    - {visibleError} -
    - ) : null} - + {visibleError} +
    + ) : null} + + ); }; diff --git a/src/experimental/control-d/ui-regional-route.tsx b/src/experimental/control-d/ui-regional-route.tsx index b99d48d..b47abe3 100644 --- a/src/experimental/control-d/ui-regional-route.tsx +++ b/src/experimental/control-d/ui-regional-route.tsx @@ -26,7 +26,7 @@ const mappingDescription = (status: ControlDMapping["status"]): string => { return "Automatically matched to the nearest exit in the same country."; } if (status === "approximate") { - return "Nearest available exit. Review this route before applying."; + return "Nearest available exit; change it if you prefer another location."; } return "This regional profile is excluded from synchronization."; }; @@ -59,15 +59,15 @@ export const ControlDRegionalRoute = ({ return (
    -
    -
    -
    {label}
    -
    - {ruleCountLabel(mapping.ruleCount ?? 0)} -
    +
    +
    {label}
    +
    + {ruleCountLabel(mapping.ruleCount ?? 0)}
    +
    +
    {editing ? ( setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && apiKey.trim() && !syncing) void connect(); + }} + /> + +
    + -
    - + {t.account.docs} +
    + )} + + ); + + const renderSetup = () => ( + +
    + {candidates.length === 0 ? ( +

    + {t.setup.none} +

    ) : ( - <> - -
    - -
    - {previewDescription ? ( -

    {previewDescription}

    + + + + {candidate.profileName} + + + {candidate.endpointName ?? t.setup.missingEndpoint} ·{" "} + {t.setup.folders(candidate.managedFolderCount)} + + + + {candidate.code} + + + {candidate.issue ? ( + + {t.setup.blocked} + + ) : null} + + ); + })} +
    + )} +
    + + {selectedCandidate ? ( + + ) : null} +
    +
    + + ); + + // eslint-disable-next-line sonarjs/cognitive-complexity + const renderRules = () => { + const diff = snapshot?.diff; + const blockingWarnings = + diff?.warnings.filter( + (warning) => + warning.code === "unsupported-pattern" || warning.code === "missing-location", + ) ?? []; + const mappings = showRoutes + ? (diff?.mappings ?? []) + : (diff?.mappings.filter((mapping) => mapping.status === "approximate") ?? []); + let applyLabel: string = t.rules.apply; + if (syncing) applyLabel = t.common.working; + else if (state?.status === "conflict") applyLabel = t.rules.repair; + return ( +
    + + {diff ? ( +
    + + {diff.addRules + diff.updateRules + diff.deleteRules === 0 && + !diff.createProfile && + !diff.createEndpoint ? ( +

    {t.rules.upToDate}

    ) : null} - {blockingWarnings.length > 0 ? ( -
      - {blockingWarnings.map((warning, index) => ( + {diff.warnings.length > 0 ? ( +
        + {diff.warnings.map((warning, index) => (
      • {warning.message}
      • ))}
      ) : null} - {diff && !state.autoSyncEnabled ? ( -
      - {diff.requiresApproximationConfirmation ? ( - - ) : null} - -
      + {diff.requiresApproximationConfirmation ? ( + ) : null} - - - {visibleError ? ( -
      - {visibleError} -
      - ) : null} - - {showMappingControls ? ( - -
      - {visibleMappings.map((mapping) => { - const selectedProxy = mapping.proxyPk - ? proxyByPk.get(mapping.proxyPk) - : undefined; - const isEditing = editingLocationId === mapping.locationId; - return ( - - setEditingLocationId(editing ? mapping.locationId : null) - } - onMappingChange={(nextMapping, proxyPk) => - void updateMapping(nextMapping, proxyPk) - } - /> - ); - })} -
      -
      - ) : null} - - {state.resolverDoh ? : null} - - - -
      +
      +
      - - - )} +
      + ) : ( + + )} +
      + {mappings.length > 0 ? ( + +
      + {mappings.map((mapping) => ( + + setEditingLocationId(editing ? mapping.locationId : null) + } + onMappingChange={(next, proxyPk) => void updateMapping(next, proxyPk)} + /> + ))} +
      +
      + ) : null} +
    + ); + }; + + const renderDns = () => ( + + {state?.resolverDoh ? ( +
    + + {state.resolverDoh.replace(/^(https:\/\/[^/]+\/).+$/, "$1••••••••")} + +
    + + + + +
    + {state.dnsStatus === "verified" ? ( +

    + {t.common.confirmedAt(formatTime(state.dnsVerifiedAt))} +

    + ) : ( +
    + +
    + )} +
    + ) : ( +

    {t.common.applyFirst}

    + )} +
    + ); - {!state?.connected && visibleError ? ( -
    ( +
    + +
    +
    +

    {t.overview.account}

    +

    {t.account.connectedTitle}

    +
    +
    +

    {t.overview.rules}

    +

    + {t.overview.syncedAt(formatTime(state?.lastSuccessAt ?? null))} +

    +
    +
    +

    {t.overview.dns}

    +

    + {state?.dnsStatus === "verified" + ? t.dns.verifiedState + : t.dns.unverifiedState} +

    +
    +
    +
    + + + +
    +
    + +
    + +
    +
    + ); + + let content = renderDns(); + if (active && stepOverride === null) content = renderOverview(); + else if (currentStep === 0) content = renderAccount(); + else if (currentStep === 1) content = renderSetup(); + else if (currentStep === 2) content = renderRules(); + + return ( +
    + } + /> + +
    +
    + {(notice ?? state?.lastError) ? ( +
    + {notice ?? state?.lastError} +
    + ) : null} +
    + {syncing ? t.common.working : stateLabel(state)}
    - ) : null} - - + {content} +
    +
    + +

    {t.help.identity}

    +

    {t.help.separation}

    +

    {t.help.retention}

    +
    +
    +
    + { + if (!open) setConfirmState(null); + }} + > + + + + + {confirmState?.kind === "adopt" + ? t.setup.confirmTitle + : t.disconnect.title} + + + {confirmState?.kind === "adopt" + ? t.setup.confirmDescription + : t.disconnect.description} + + + + + + + + +
    ); }; + +export const ControlDPanel = ControlDSubpage; diff --git a/src/experimental/control-d/ui-regional-route.tsx b/src/experimental/control-d/ui-regional-route.tsx index b47abe3..4cc93b1 100644 --- a/src/experimental/control-d/ui-regional-route.tsx +++ b/src/experimental/control-d/ui-regional-route.tsx @@ -1,4 +1,5 @@ import type { ControlDMapping, ControlDProxyLocation } from "./contracts"; +import { controlDText as t } from "./ui-copy"; import { Button } from "@/ui/components/ui/button"; import { @@ -9,26 +10,25 @@ import { SelectValue, } from "@/ui/components/ui/select"; -const ruleCountLabel = (count: number): string => - `${count} ${count === 1 ? "rule" : "rules"}`; +const ruleCountLabel = (count: number): string => t.route.ruleCount(count); const proxyLabel = ( mapping: ControlDMapping, proxy: ControlDProxyLocation | undefined, ): string => { if (proxy) return `${proxy.city}, ${proxy.countryName}`; - if (mapping.status === "skipped") return "Not synchronized"; - return "Control D exit unavailable"; + if (mapping.status === "skipped") return t.route.notSynchronized; + return t.route.unavailable; }; const mappingDescription = (status: ControlDMapping["status"]): string => { if (status === "exact") { - return "Automatically matched to the nearest exit in the same country."; + return t.route.exact; } if (status === "approximate") { - return "Nearest available exit; change it if you prefer another location."; + return t.route.approximate; } - return "This regional profile is excluded from synchronization."; + return t.route.skipped; }; type RouteProps = { @@ -52,9 +52,9 @@ export const ControlDRegionalRoute = ({ }: RouteProps) => { const label = mapping.locationLabel ?? mapping.locationId; const selectedProxyLabel = proxyLabel(mapping, proxy); - let actionLabel = "Change"; - if (editing) actionLabel = "Cancel"; - else if (mapping.status === "skipped" || !proxy) actionLabel = "Choose exit"; + let actionLabel: string = t.route.change; + if (editing) actionLabel = t.common.cancel; + else if (mapping.status === "skipped" || !proxy) actionLabel = t.route.choose; return (
    - + {selectedProxyLabel} - Do not synchronize + {t.route.skip} {proxies.map((availableProxy) => ( {availableProxy.city}, {availableProxy.countryName} diff --git a/src/stubs/experimental-control-d-ui.tsx b/src/stubs/experimental-control-d-ui.tsx index 56d5ae5..33329ba 100644 --- a/src/stubs/experimental-control-d-ui.tsx +++ b/src/stubs/experimental-control-d-ui.tsx @@ -4,4 +4,6 @@ export const ControlDFeatureToggle = (_props: { export const ControlDPanel = () => null; +export const ControlDSubpage = () => null; + export const isIntegrationAvailable = (): boolean => false; diff --git a/src/ui/options/components/tabs/AboutTab.tsx b/src/ui/options/components/tabs/AboutTab.tsx index fa0a694..f51619d 100644 --- a/src/ui/options/components/tabs/AboutTab.tsx +++ b/src/ui/options/components/tabs/AboutTab.tsx @@ -37,7 +37,14 @@ const getReleaseChannelLabel = (channel: "local" | "beta" | "stable"): string => }; const renderAboutSubpage = ( - view: "privacyPolicy" | "thirdPartyNotices" | "license" | "none" | "logs" | null, + view: + | "privacyPolicy" + | "thirdPartyNotices" + | "license" + | "none" + | "logs" + | "experimentalIntegration" + | null, ) => { if (view === "privacyPolicy") return ( diff --git a/src/ui/options/components/tabs/AdvancedTab.tsx b/src/ui/options/components/tabs/AdvancedTab.tsx index 18a2496..12f7485 100644 --- a/src/ui/options/components/tabs/AdvancedTab.tsx +++ b/src/ui/options/components/tabs/AdvancedTab.tsx @@ -2,7 +2,7 @@ import React from "react"; import { ControlDFeatureToggle, - ControlDPanel, + ControlDSubpage, isIntegrationAvailable as isExperimentalIntegrationAvailable, } from "@/experimental/control-d/ui-entry"; import { cn } from "@/ui/components/lib/utils"; @@ -358,13 +358,11 @@ const DangerCard = () => { const AdvancedOverview = () => { const { highlightedAnchorId } = useSettings(); - const [controlDEnabled, setControlDEnabled] = React.useState(false); return (
    - - {controlDEnabled ? : null} + undefined} />
    @@ -384,15 +382,22 @@ const AdvancedOverview = () => { export const AdvancedTab = () => { const { logsHostFilter, settingsSubpageView } = useSettings(); + const showExperiment = + settingsSubpageView === "experimentalIntegration" && + isExperimentalIntegrationAvailable(); + let content = ; + if (settingsSubpageView === "logs") { + content = ( + + + + ); + } else if (showExperiment) { + content = ; + } return ( - {settingsSubpageView === "logs" ? ( - - - - ) : ( - - )} + {content} ); }; diff --git a/src/ui/options/navigation.ts b/src/ui/options/navigation.ts index 6a6c210..915d411 100644 --- a/src/ui/options/navigation.ts +++ b/src/ui/options/navigation.ts @@ -36,6 +36,7 @@ export const PAGE_ANCHORS: Record = { export const SETTINGS_SUBPAGE_ANCHORS = { logs: "page-logs", + experimentalIntegration: "page-experimental-integration", privacyPolicy: "page-privacy-policy", thirdPartyNotices: "page-third-party-notices", license: "page-license", @@ -146,6 +147,7 @@ const STATIC_ANCHOR_TO_TAB: Record = { [PAGE_ANCHORS.advanced]: "advanced", [PAGE_ANCHORS.about]: "about", [SETTINGS_SUBPAGE_ANCHORS.logs]: "advanced", + [SETTINGS_SUBPAGE_ANCHORS.experimentalIntegration]: "advanced", [SETTINGS_SUBPAGE_ANCHORS.privacyPolicy]: "about", [SETTINGS_SUBPAGE_ANCHORS.thirdPartyNotices]: "about", [SETTINGS_SUBPAGE_ANCHORS.license]: "about", @@ -224,6 +226,7 @@ const ANCHOR_ALIASES: Record = { const SUBPAGE_VIEW_BY_ANCHOR: Partial> = { [SETTINGS_SUBPAGE_ANCHORS.logs]: "logs", + [SETTINGS_SUBPAGE_ANCHORS.experimentalIntegration]: "experimentalIntegration", [SETTINGS_SUBPAGE_ANCHORS.privacyPolicy]: "privacyPolicy", [SETTINGS_SUBPAGE_ANCHORS.thirdPartyNotices]: "thirdPartyNotices", [SETTINGS_SUBPAGE_ANCHORS.license]: "license", diff --git a/src/ui/options/stories/ControlD.stories.tsx b/src/ui/options/stories/ControlD.stories.tsx index 48e9e24..2b471e9 100644 --- a/src/ui/options/stories/ControlD.stories.tsx +++ b/src/ui/options/stories/ControlD.stories.tsx @@ -1,17 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { expect, userEvent, waitFor, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { CONTROL_D_COMMANDS, type ControlDDiff, type ControlDPreparedSnapshot, type ControlDPublicState, + type ControlDRecoveryCandidate, } from "../../../experimental/control-d/contracts"; -import { ControlDPanel } from "../../../experimental/control-d/ui-entry"; +import { ControlDSubpage } from "../../../experimental/control-d/ui-entry"; import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; import { DEFAULT_PREFERENCES } from "@/shared/settings-defaults"; import type { ThemeMode } from "@/shared/types"; +import { AppPageFrame } from "@/ui/shared/AppPageFrame"; import { ThemeProvider } from "@/ui/shared/ThemeProvider"; const baseState: ControlDPublicState = { @@ -20,10 +22,14 @@ const baseState: ControlDPublicState = { autoSyncEnabled: true, status: "ready", hasApiKey: true, + setupStatus: "selected", + resourceCode: "ABCDE-FGHJK", profileId: "profile-1", endpointId: "device-1", hasResolver: true, resolverDoh: "https://dns.controld.com/private-resolver-token", + dnsStatus: "verified", + dnsVerifiedAt: "2026-09-10T14:30:00.000Z", lastAttemptAt: "2026-09-10T14:28:00.000Z", lastSuccessAt: "2026-09-10T14:28:00.000Z", lastError: null, @@ -93,25 +99,99 @@ const proxies = [ longitude: -75.7, }, ]; - const snapshot: ControlDPreparedSnapshot = { diff, proxies }; +const firstSnapshot: ControlDPreparedSnapshot = { + proxies, + diff: { + ...diff, + createProfile: true, + createEndpoint: true, + createFolders: 2, + addRules: 6, + unchangedRules: 0, + }, +}; +const approximateSnapshot: ControlDPreparedSnapshot = { + proxies: [ + ...proxies, + { + pk: "GRU", + city: "Sao Paulo", + countryCode: "BR", + countryName: "Brazil", + latitude: -23.55, + longitude: -46.63, + }, + ], + diff: { + ...firstSnapshot.diff, + warnings: [ + { + code: "approximate-location", + locationId: "rio", + message: "Control D has no exit in Brazil; Rio maps to Sao Paulo.", + }, + ], + mappings: [ + { + locationId: "rio", + locationLabel: "Rio de Janeiro", + ruleCount: 1, + proxyPk: "GRU", + status: "approximate", + confirmed: false, + }, + ], + requiresApproximationConfirmation: true, + }, +}; +const candidates: ControlDRecoveryCandidate[] = [ + { + code: "ABCDE-FGHJK", + profileId: "profile-1", + profileName: "Privacy Thing ABCDE-FGHJK", + endpointId: "device-1", + endpointName: "PT Browser ABCDE-FGHJK", + managedFolderCount: 3, + compatibility: "ready", + issue: null, + }, + { + code: "MNPQR-STVWX", + profileId: "profile-2", + profileName: "Privacy Thing MNPQR-STVWX", + endpointId: null, + endpointName: null, + managedFolderCount: 2, + compatibility: "profile-only", + issue: null, + }, +]; const installBoundary = ( state: ControlDPublicState, - themeMode: ThemeMode = "light", - preparedSnapshot: ControlDPreparedSnapshot = snapshot, + preparedSnapshot: ControlDPreparedSnapshot, + recoveryCandidates: ControlDRecoveryCandidate[], + themeMode: ThemeMode, ): void => { Reflect.set(globalThis, "chrome", { runtime: { id: "storybook-control-d", sendMessage: async (message: { type?: string }) => { if ( - message.type === CONTROL_D_COMMANDS.preview || - message.type === CONTROL_D_COMMANDS.syncNow || - message.type === CONTROL_D_COMMANDS.apply || - message.type === CONTROL_D_COMMANDS.repair + message.type === CONTROL_D_COMMANDS.discover || + message.type === CONTROL_D_COMMANDS.connect + ) { + return { ok: true, state, candidates: recoveryCandidates }; + } + if ( + [ + CONTROL_D_COMMANDS.preview, + CONTROL_D_COMMANDS.syncNow, + CONTROL_D_COMMANDS.apply, + CONTROL_D_COMMANDS.repair, + ].includes(message.type as never) ) { - await Promise.resolve(); return { ok: true, state, snapshot: preparedSnapshot }; } return { ok: true, state }; @@ -119,26 +199,18 @@ const installBoundary = ( getManifest: () => ({ optional_host_permissions: ["https://api.controld.com/*"], }), + getURL: (path: string) => path, }, - permissions: { - contains: async () => true, - request: async () => true, - }, + permissions: { contains: async () => true, request: async () => true }, storage: { local: { get: async () => ({ - [EXTENSION_STORAGE_KEYS.preferences]: { - ...DEFAULT_PREFERENCES, - themeMode, - }, + [EXTENSION_STORAGE_KEYS.preferences]: { ...DEFAULT_PREFERENCES, themeMode }, }), set: async () => undefined, remove: async () => undefined, }, - onChanged: { - addListener: () => undefined, - removeListener: () => undefined, - }, + onChanged: { addListener: () => undefined, removeListener: () => undefined }, }, tabs: { create: async () => undefined }, }); @@ -146,201 +218,127 @@ const installBoundary = ( const Surface = ({ state, - themeMode, - preparedSnapshot, + preparedSnapshot = snapshot, + recoveryCandidates = [], + themeMode = "light", }: { state: ControlDPublicState; - themeMode?: ThemeMode; preparedSnapshot?: ControlDPreparedSnapshot; + recoveryCandidates?: ControlDRecoveryCandidate[]; + themeMode?: ThemeMode; }) => { - installBoundary(state, themeMode, preparedSnapshot); + installBoundary(state, preparedSnapshot, recoveryCandidates, themeMode); return ( -
    - -
    + + +
    ); }; const meta = { title: "Options/Control D", - component: ControlDPanel, + component: ControlDSubpage, parameters: { layout: "fullscreen", privacyThing: { surface: "options" } }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -export const Disconnected: Story = { - render: () => ( - - ), +const disconnected: ControlDPublicState = { + ...baseState, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + hasApiKey: false, + setupStatus: "unselected", + resourceCode: null, + profileId: null, + endpointId: null, + hasResolver: false, + resolverDoh: null, + dnsStatus: "unavailable", + dnsVerifiedAt: null, + lastAttemptAt: null, + lastSuccessAt: null, }; -export const Ready: Story = { - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - await canvas.findByRole("button", { name: "Manage route overrides" }), - ).toBeVisible(); - await expect(canvas.queryByText("Route overrides")).not.toBeInTheDocument(); - await expect(canvas.queryByText("Warsaw, Poland")).not.toBeInTheDocument(); - await expect(canvas.queryByText("Ottawa, Canada")).not.toBeInTheDocument(); - await expect(canvas.queryByText("Paris, France")).not.toBeInTheDocument(); - await expect(canvas.queryAllByRole("combobox")).toHaveLength(0); - await expect(canvas.queryByText("Folders to create")).not.toBeInTheDocument(); - await expect(canvas.queryByText("Preview changes")).not.toBeInTheDocument(); - await expect(canvas.queryByText(/need review/i)).not.toBeInTheDocument(); - - await userEvent.click( - canvas.getByRole("button", { name: "Manage route overrides" }), - ); - await expect(await canvas.findByText("Route overrides")).toBeVisible(); - await expect(canvas.getByText("Warsaw, Poland")).toBeVisible(); - await expect(canvas.getByText("Ottawa, Canada")).toBeVisible(); - await expect(canvas.getByText("Paris, France")).toBeVisible(); - await expect(canvas.queryAllByRole("combobox")).toHaveLength(0); - await expect(canvas.getByText("Control D is up to date.")).toBeVisible(); - - await userEvent.click(canvas.getAllByRole("button", { name: "Change" })[0]!); - const exitSelect = canvas.getByRole("combobox", { - name: "Choose Control D exit for Warsaw", - }); - await expect(exitSelect).toHaveTextContent("Warsaw, Poland"); - await userEvent.click(exitSelect); - await expect( - await within(document.body).findByRole("option", { name: "Warsaw, Poland" }), - ).toBeVisible(); - await userEvent.keyboard("{Escape}"); - - await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); - await userEvent.click(canvas.getByRole("button", { name: "Sync now" })); - await expect(canvas.getByText("Warsaw, Poland")).toBeVisible(); - await expect( - canvas.queryByText("Control D exit unavailable"), - ).not.toBeInTheDocument(); - }, +const choosing: ControlDPublicState = { + ...disconnected, + connected: true, + status: "ready", + hasApiKey: true, }; - -const approximateSnapshot: ControlDPreparedSnapshot = { - proxies: [ - ...proxies, - { - pk: "GRU", - city: "Sao Paulo", - countryCode: "BR", - countryName: "Brazil", - latitude: -23.55, - longitude: -46.63, - }, - ], - diff: { - ...diff, - unchangedRules: 0, - addRules: 1, - warnings: [ - { - code: "approximate-location", - locationId: "rio", - message: "Rio de Janeiro uses the nearest available exit in Sao Paulo.", - }, - ], - mappings: [ - { - locationId: "rio", - locationLabel: "Rio de Janeiro", - ruleCount: 1, - proxyPk: "GRU", - status: "approximate", - confirmed: false, - }, - ], - requiresApproximationConfirmation: true, - }, +const firstSync: ControlDPublicState = { + ...baseState, + autoSyncEnabled: false, + profileId: null, + endpointId: null, + hasResolver: false, + resolverDoh: null, + dnsStatus: "unavailable", + dnsVerifiedAt: null, + lastAttemptAt: null, + lastSuccessAt: null, +}; +const dnsPending: ControlDPublicState = { + ...baseState, + dnsStatus: "pending", + dnsVerifiedAt: null, }; +export const Account: Story = { render: () => }; +export const NoExistingSetup: Story = { render: () => }; +export const ExistingSetups: Story = { + render: () => , +}; export const FirstSynchronization: Story = { - render: () => ( - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(await canvas.findByText("Sao Paulo, Brazil")).toBeVisible(); - await expect( - canvas.getByText( - "Nearest available exit; change it if you prefer another location.", - ), - ).toBeVisible(); - await expect(canvas.queryByText("Preview changes")).not.toBeInTheDocument(); - await expect( - canvas.getByRole("button", { name: "Apply synchronization" }), - ).toBeDisabled(); - await userEvent.click( - canvas.getByText("I accept the cross-country fallback shown below."), - ); - await expect( - canvas.getByRole("button", { name: "Apply synchronization" }), - ).toBeEnabled(); - }, + render: () => , }; - +export const ApproximateRoute: Story = { + render: () => , +}; +export const BrowserDns: Story = { render: () => }; +export const Active: Story = { render: () => }; export const Conflict: Story = { render: () => ( ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitFor(() => - expect( - canvas.getByRole("button", { name: "Repair managed rules" }), - ).toBeVisible(), - ); - }, }; - export const Syncing: Story = { render: () => , }; - -export const DarkReady: Story = { +export const DarkActive: Story = { render: () => , +}; + +export const SelectInteraction: Story = { + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(await canvas.findByText("Sao Paulo, Brazil")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Change" })); + const select = canvas.getByRole("combobox", { + name: "Choose Control D exit for Rio de Janeiro", + }); + await userEvent.click(select); await expect( - await canvas.findByRole("button", { name: "Manage route overrides" }), + await within(document.body).findByRole("option", { name: "Warsaw, Poland" }), ).toBeVisible(); - await expect(canvas.queryByText("Warsaw, Poland")).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); }, }; From a71a9e8f44bb9f9d6a8690d2f47abba672edf657 Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:19:02 +0200 Subject: [PATCH 08/11] fix(control-d): restore responsive setup UI Experimental sources were excluded from Tailwind scanning, so unique responsive utilities disappeared from beta builds. --- config/tailwind.config.ts | 6 +++ src/experimental/control-d/ui-copy.ts | 25 ++++++++--- src/experimental/control-d/ui-entry.tsx | 49 +++++++++++++-------- src/ui/options/stories/ControlD.stories.tsx | 18 +++++++- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/config/tailwind.config.ts b/config/tailwind.config.ts index c418f97..db3b764 100644 --- a/config/tailwind.config.ts +++ b/config/tailwind.config.ts @@ -1,5 +1,10 @@ import type { Config } from "tailwindcss"; +const experimentalSources = + process.env.PT_BUILD_CHANNEL === "release" + ? [] + : ["../src/experimental/**/*.{ts,tsx,html}"]; + export default { darkMode: ["selector", '[data-theme="dark"]'], // Tailwind resolves plain content globs against the process cwd, not this @@ -11,6 +16,7 @@ export default { "../.storybook/**/*.{ts,tsx,mdx}", "../src/ui/**/*.{ts,tsx,html}", "../packages/ui/src/**/*.{ts,tsx}", + ...experimentalSources, ], }, theme: { diff --git a/src/experimental/control-d/ui-copy.ts b/src/experimental/control-d/ui-copy.ts index 7c84d69..9711544 100644 --- a/src/experimental/control-d/ui-copy.ts +++ b/src/experimental/control-d/ui-copy.ts @@ -119,11 +119,26 @@ export const controlDText = { "Apply synchronization first to create the browser endpoint and resolver.", }, help: { - title: "How this works", - identity: - "Privacy Thing only manages resources carrying the setup code shown here.", - separation: - "Account access, rule synchronization, and browser DNS are separate steps.", + account: { + title: "API key and privacy", + body: "Privacy Thing requests Control D access only after you press Connect. The key stays in this browser installation.", + }, + setup: { + title: "New or existing setup", + body: "Choose whether to create an isolated setup or reconnect one carrying a Privacy Thing setup code.", + }, + rules: { + title: "Review before writing", + body: "Nothing changes in Control D until you review the planned changes and apply synchronization.", + }, + dns: { + title: "DNS stays under your control", + body: "Privacy Thing provides the resolver, but you configure Secure DNS and verify it in the browser yourself.", + }, + overview: { + title: "Independent states", + body: "API access, synchronized rules, and verified browser DNS are tracked separately.", + }, retention: "Remote resources are never removed automatically.", }, route: { diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx index 357e6cf..bd0d858 100644 --- a/src/experimental/control-d/ui-entry.tsx +++ b/src/experimental/control-d/ui-entry.tsx @@ -186,7 +186,11 @@ const StepRail = ({ dnsVerified: boolean; onSelect: (step: FlowStep) => void; }) => ( -
      +
        {t.steps.map((label, index) => { const step = index as FlowStep; const active = current === step; @@ -199,20 +203,18 @@ const StepRail = ({ aria-current={active ? "step" : undefined} onClick={() => onSelect(step)} className={cn( - "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition-colors", - active && "border-primary bg-primary/8 text-foreground", - !active && - complete && - "border-tone-success-border bg-tone-success-bg/45 text-foreground", - !active && !complete && "border-border bg-muted/25 text-muted-foreground", - step <= furthest && "hover:bg-muted/60", + "flex min-h-9 w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-1.5 text-left text-xs transition-colors", + active && "border-primary/50 bg-background text-foreground shadow-sm", + !active && complete && "bg-tone-success-bg/35 text-foreground", + !active && !complete && "text-muted-foreground", + step <= furthest && "hover:bg-background/70", )} > {complete && !active ? "✓" : index + 1} @@ -476,7 +478,8 @@ export const ControlDSubpage = () => { - - ); - })} -
      +
      + onSelect(step as FlowStep)} + /> +
      ); const ChangeSummary = ({ diff }: { diff: ControlDDiff }) => { @@ -847,14 +816,12 @@ export const ControlDSubpage = () => { backAriaLabel={t.back} backIconOnly backHref={`#${PAGE_ANCHORS.advanced}`} - actions={} />
      {(notice ?? state?.lastError) ? ( @@ -871,9 +838,9 @@ export const ControlDSubpage = () => { {content}
      - +

      {help.body}

      -

      {t.help.retention}

      +

      {help.note}

      diff --git a/src/ui/options/components/onboarding/setup-progress.tsx b/src/ui/options/components/onboarding/setup-progress.tsx new file mode 100644 index 0000000..81b54ae --- /dev/null +++ b/src/ui/options/components/onboarding/setup-progress.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/ui/components/lib/utils"; + +const STEP_COUNT = 4; + +export const SetupProgress = ({ + active, + label, + stepLabels, + onSelect, + selectableUntil, +}: { + active: number; + label: string; + stepLabels?: readonly string[]; + onSelect?: (step: number) => void; + selectableUntil?: number; +}) => { + const fillScale = (active - 1) / (STEP_COUNT - 1); + return ( +
      +
      +
      +
      + {Array.from({ length: STEP_COUNT }, (_, index) => { + const item = index + 1; + const reached = item <= active; + const className = cn( + "relative z-[1] grid h-[22px] w-[22px] place-items-center rounded-full border text-xs font-semibold leading-none transition-[background-color,border-color,color,transform] duration-300", + reached + ? "scale-100 border-primary bg-primary text-primary-foreground" + : "scale-95 border-border bg-background text-muted-foreground", + ); + if (!onSelect) { + return ( +
      + {item} +
      + ); + } + const stepLabel = stepLabels?.[index]; + return ( + + ); + })} +
      +
      + ); +}; diff --git a/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx b/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx index deae91e..0c99c64 100644 --- a/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx +++ b/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import privacyPolicyMarkdown from "../../../../../PRIVACY.md?raw"; -import { cn } from "@/ui/components/lib/utils"; import { Dialog, DialogCloseButton, @@ -12,6 +11,7 @@ import { DialogTitle, } from "@/ui/components/ui/dialog"; import { t } from "@/ui/i18n"; +import { SetupProgress } from "@/ui/options/components/onboarding/setup-progress"; import type { WizardStep } from "@/ui/options/components/onboarding/WelcomeWizard"; import { NUMERIC_ROLLING_ALPHABET, @@ -37,34 +37,7 @@ const PARENT_PROGRESS: Record = { export const WizardProgress = ({ step }: { step: WizardStep }) => { const active = PARENT_PROGRESS[step]; if (!active) return null; - const fillScale = (active - 1) / 3; - return ( -
      -
      -
      -
      - {[1, 2, 3, 4].map((item) => ( -
      - {item} -
      - ))} -
      -
      - ); + return ; }; const OdometerDigit = ({ diff --git a/src/ui/options/stories/ControlD.stories.tsx b/src/ui/options/stories/ControlD.stories.tsx index 50d6340..d5c5928 100644 --- a/src/ui/options/stories/ControlD.stories.tsx +++ b/src/ui/options/stories/ControlD.stories.tsx @@ -296,14 +296,10 @@ export const Account: Story = { render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const progress = canvas.getByRole("list", { - name: "Control D setup progress", - }); - const columns = window.getComputedStyle(progress).gridTemplateColumns.split(" "); - let expectedColumnCount = 1; - if (window.innerWidth >= 1024) expectedColumnCount = 4; - else if (window.innerWidth >= 640) expectedColumnCount = 2; - await expect(columns).toHaveLength(expectedColumnCount); + const progress = canvas.getByLabelText("Control D setup progress"); + await expect(progress.querySelectorAll("button")).toHaveLength(4); + await expect(canvas.getByRole("button", { name: "Control D account" })).toBeEnabled(); + await expect(canvas.getByRole("button", { name: "Choose setup" })).toBeDisabled(); await expect( canvas.getByRole("button", { name: "API instructions" }), ).toBeVisible(); From 22e80229e0e87dbdca12f0facada9b50c382b696 Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:05:29 +0200 Subject: [PATCH 10/11] fix(control-d): keep setup in one panel Place the alert under the step title. Treat a Privacy Thing profile as valid when it is the endpoint's second profile. Co-authored-by: Cursor --- src/experimental/control-d/client.test.ts | 28 ++++++ src/experimental/control-d/client.ts | 33 ++++++- .../control-d/reconcile.target.test.ts | 19 +++++ src/experimental/control-d/reconcile.ts | 11 ++- src/experimental/control-d/recovery.ts | 8 +- src/experimental/control-d/ui-entry.tsx | 85 ++++++++++++------- 6 files changed, 145 insertions(+), 39 deletions(-) diff --git a/src/experimental/control-d/client.test.ts b/src/experimental/control-d/client.test.ts index e50fbbf..1ea3df8 100644 --- a/src/experimental/control-d/client.test.ts +++ b/src/experimental/control-d/client.test.ts @@ -252,6 +252,34 @@ describe("ControlDClient", () => { ]); }); + it("reads a second enforced profile from the endpoint", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + devices: [ + { + PK: "device-1", + name: "PT-Browser", + profile: { PK: "main-profile", name: "Main" }, + profile2: { PK: "privacy-profile", name: "Privacy Thing" }, + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listDevices()).resolves.toEqual([ + { + id: "device-1", + name: "PT-Browser", + profileId: "main-profile", + enforcedProfileIds: ["main-profile", "privacy-profile"], + resolverDoh: "https://dns.controld.com/secret", + }, + ]); + }); + it("respects Retry-After for a safe request", async () => { vi.useFakeTimers(); const onRetry = vi.fn(); diff --git a/src/experimental/control-d/client.ts b/src/experimental/control-d/client.ts index a11706d..92f4a69 100644 --- a/src/experimental/control-d/client.ts +++ b/src/experimental/control-d/client.ts @@ -29,6 +29,10 @@ const asRecord = (value: unknown): UnknownRecord => (isRecord(value) ? value : { const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); const asString = (value: unknown): string | null => typeof value === "string" && value.length > 0 ? value : null; +const profileKey = (value: unknown): string | null => { + const id = asString(value); + return id && id !== "-1" ? id : null; +}; const asNumber = (value: unknown): number | null => { const parsed = typeof value === "number" ? value : Number(value); return Number.isFinite(parsed) ? parsed : null; @@ -87,8 +91,21 @@ export type ControlDDevice = { id: string; name: string; profileId: string | null; + enforcedProfileIds?: readonly string[]; resolverDoh: string | null; }; + +export const deviceProfileIds = (device: ControlDDevice): readonly string[] => + device.enforcedProfileIds ?? (device.profileId ? [device.profileId] : []); + +export const deviceUsesAnotherProfile = ( + device: ControlDDevice, + managedProfileId: string | null | undefined, +): boolean => { + if (!managedProfileId) return false; + const ids = deviceProfileIds(device); + return ids.length > 0 && !ids.includes(managedProfileId); +}; export type ControlDRetryEvent = { attempt: number; delayMs: number; @@ -388,6 +405,17 @@ export class ControlDClient { const record = asRecord(entry); const resolvers = asRecord(record.resolvers); const profile = asRecord(record.profile); + const profile2 = asRecord(record.profile2); + const profile3 = asRecord(record.profile3); + const enforcedProfileIds = [ + ...new Set( + [ + profileKey(record.profile_id ?? profile.PK ?? profile.id), + profileKey(record.profile_id2 ?? profile2.PK ?? profile2.id), + profileKey(record.profile_id3 ?? profile3.PK ?? profile3.id), + ].filter((id): id is string => id !== null), + ), + ]; const id = asString(record.PK ?? record.pk ?? record.id); const name = asString(record.name); return id && name @@ -395,7 +423,8 @@ export class ControlDClient { { id, name, - profileId: asString(record.profile_id ?? profile.PK ?? profile.id), + profileId: enforcedProfileIds[0] ?? null, + enforcedProfileIds, resolverDoh: asString(resolvers.doh ?? record.doh), }, ] @@ -452,7 +481,7 @@ export class ControlDClient { const id = asString(record.PK ?? record.pk ?? record.id); const resolvers = asRecord(record.resolvers); const resolverDoh = asString(resolvers.doh ?? record.doh); - if (id) return { id, name, profileId, resolverDoh }; + if (id) return { id, name, profileId, enforcedProfileIds: [profileId], resolverDoh }; } return null; } diff --git a/src/experimental/control-d/reconcile.target.test.ts b/src/experimental/control-d/reconcile.target.test.ts index 163d8a3..b7a1f96 100644 --- a/src/experimental/control-d/reconcile.target.test.ts +++ b/src/experimental/control-d/reconcile.target.test.ts @@ -296,6 +296,25 @@ describe("Control D reconcile", () => { }); }); + it("accepts a saved endpoint when the managed profile is the second enforced profile", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.devices[0]!.profileId = "main-profile"; + fake.devices[0]!.enforcedProfileIds = ["main-profile", "profile-1"]; + + await expect(prepareControlDSync(asClient(fake), applied)).resolves.toMatchObject({ + diff: expect.objectContaining({ createEndpoint: false }), + }); + }); + it("rejects a saved endpoint reassigned to another profile", async () => { const fake = new FakeClient(); const initial = config(); diff --git a/src/experimental/control-d/reconcile.ts b/src/experimental/control-d/reconcile.ts index 7993d25..b34fca8 100644 --- a/src/experimental/control-d/reconcile.ts +++ b/src/experimental/control-d/reconcile.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function, max-params, sonarjs/cognitive-complexity -- Reconcile keeps remote ownership checks in one module. */ import type { ControlDClient } from "./client"; -import { ControlDApiError, type ControlDRule } from "./client"; +import { ControlDApiError, deviceUsesAnotherProfile, type ControlDRule } from "./client"; import { compileControlDState, type ControlDCompilation } from "./compiler"; import type { ControlDDiff, @@ -223,7 +223,10 @@ export const prepareControlDSync = async ( if (config.endpointId && !knownEndpoint) { throw new ControlDConflictError("The managed Control D endpoint is missing."); } - if (knownEndpoint?.profileId && knownEndpoint.profileId !== knownProfile?.id) { + if ( + knownEndpoint && + deviceUsesAnotherProfile(knownEndpoint, knownProfile?.id) + ) { throw new ControlDConflictError( "The managed Control D endpoint uses another profile.", ); @@ -291,7 +294,7 @@ const ensureEndpoint = async ( (device) => device.id === config.endpointId, ); if (!existing) throw new ControlDConflictError("The managed endpoint is missing."); - if (existing.profileId && existing.profileId !== profileId) { + if (deviceUsesAnotherProfile(existing, profileId)) { throw new ControlDConflictError("The managed endpoint uses another profile."); } return { id: existing.id, resolverDoh: existing.resolverDoh }; @@ -307,7 +310,7 @@ const ensureEndpoint = async ( ); } if (existing[0]) { - if (existing[0].profileId && existing[0].profileId !== profileId) { + if (deviceUsesAnotherProfile(existing[0], profileId)) { throw new ControlDConflictError("The recoverable endpoint uses another profile."); } return { id: existing[0].id, resolverDoh: existing[0].resolverDoh }; diff --git a/src/experimental/control-d/recovery.ts b/src/experimental/control-d/recovery.ts index d782c0c..354e5e2 100644 --- a/src/experimental/control-d/recovery.ts +++ b/src/experimental/control-d/recovery.ts @@ -1,4 +1,4 @@ -import type { ControlDClient, ControlDRule } from "./client"; +import { deviceProfileIds, type ControlDClient, type ControlDRule } from "./client"; import type { ControlDConfig, ControlDManagedFolder, @@ -47,8 +47,8 @@ export const discoverRecoverySets = async ( const namedEndpoints = devices.filter((device) => isControlDEndpointName(device.name, code), ); - const endpoints = namedEndpoints.filter( - (device) => device.profileId === profile.id, + const endpoints = namedEndpoints.filter((device) => + deviceProfileIds(device).includes(profile.id), ); const routes = new Set(); let duplicateRoute = false; @@ -122,7 +122,7 @@ export const adoptRecoverySet = async ({ ? devices.find( (device) => device.id === endpointId && - device.profileId === profileId && + deviceProfileIds(device).includes(profileId) && isControlDEndpointName(device.name, code), ) : undefined; diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx index 1210907..d6cfd02 100644 --- a/src/experimental/control-d/ui-entry.tsx +++ b/src/experimental/control-d/ui-entry.tsx @@ -20,6 +20,7 @@ import { cn } from "@/ui/components/lib/utils"; import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; import { SettingsHelpCard } from "@/ui/components/SettingsHelpCard"; import { Button } from "@/ui/components/ui/button"; +import { Card } from "@/ui/components/ui/card"; import { Checkbox } from "@/ui/components/ui/checkbox"; import { Dialog, @@ -70,6 +71,23 @@ export const isIntegrationAvailable = (): boolean => const settingTitle = (text: string) => (

      {text}

      ); + +const StepSection = ({ + alert, + children, + ...props +}: React.ComponentProps & { + alert?: React.ReactNode; +}) => ( + + {alert ?
      {alert}
      : null} + {children} +
      +); const formatTime = (value: string | null): string => value ? new Date(value).toLocaleString() : t.common.notYet; @@ -406,8 +424,19 @@ export const ControlDSubpage = () => { } }; + const stepAlert = + notice ?? state?.lastError ? ( +
      + {notice ?? state?.lastError} +
      + ) : null; + const renderAccount = () => ( - {
      )} - + ); const renderSetup = () => ( - @@ -539,7 +569,7 @@ export const ControlDSubpage = () => { ) : null}
      - + ); // eslint-disable-next-line sonarjs/cognitive-complexity @@ -558,7 +588,8 @@ export const ControlDSubpage = () => { else if (state?.status === "conflict") applyLabel = t.rules.repair; return (
      - @@ -621,9 +652,9 @@ export const ControlDSubpage = () => { {syncing ? t.common.working : t.rules.preview} )} - + {mappings.length > 0 ? ( - @@ -643,14 +674,15 @@ export const ControlDSubpage = () => { /> ))}
      - + ) : null}
      ); }; const renderDns = () => ( - { ) : (

      {t.common.applyFirst}

      )} -
      + ); const renderOverview = () => (
      - @@ -778,7 +811,7 @@ export const ControlDSubpage = () => { {syncing ? t.common.working : t.rules.sync}
      - +