Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ The format is based on Keep a Changelog and the project follows Semantic Version

### 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. The dedicated four-step flow can
explicitly recover setups created with the v2 Privacy Thing naming scheme
after reinstalling, while release builds preserve but never load its private
storage namespace.
- Added complete Spanish and Portuguese interface translations, selected
automatically from the browser interface language with English as the fallback.
- Added complete Russian and Ukrainian interface translations, selected
Expand All @@ -27,6 +34,11 @@ 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, 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.
- Keep popup rule-editor Selects open through leftover opening-pointer dismisses
and host window resize/blur that Radix treats as dismiss, without replacing
the shared Select.
Expand Down
7 changes: 7 additions & 0 deletions config/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
} = {}) => {
Expand Down Expand Up @@ -69,6 +71,11 @@ export const createManifest = ({
}
: {}),
host_permissions: ["<all_urls>"],
...(buildChannel === "release"
? {}
: {
optional_host_permissions: ["https://api.controld.com/*"],
}),
background: {
service_worker: "src/background/index.ts",
type: "module",
Expand Down
6 changes: 6 additions & 0 deletions config/tailwind.config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +16,7 @@ export default {
"../.storybook/**/*.{ts,tsx,mdx}",
"../src/ui/**/*.{ts,tsx,html}",
"../packages/ui/src/**/*.{ts,tsx}",
...experimentalSources,
],
},
theme: {
Expand Down
18 changes: 18 additions & 0 deletions config/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
? [
{
Expand Down
11 changes: 10 additions & 1 deletion src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -339,6 +343,11 @@ const {
// messages can reach the background router.
registerRewriteListeners();

registerControlD({
getDebugMode: async () =>
runtimeState.getLastKnownDebugMode() ?? (await getPreferences()).debugMode,
});

registerMessageRouter({
isSupportedWebUrl,
getControlState,
Expand Down
3 changes: 3 additions & 0 deletions src/background/location-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions src/background/storage-namespace-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { migrateRetiredNamespace } from "./storage-namespace-migration";

const localState: Record<string, unknown> = {};
const sessionState: Record<string, unknown> = {};

const select = (state: Record<string, unknown>, keys: readonly string[]) =>
Object.fromEntries(
keys.filter((key) => key in state).map((key) => [key, state[key]]),
);

beforeEach(() => {
for (const key of Object.keys(localState)) Reflect.deleteProperty(localState, key);
for (const key of Object.keys(sessionState))
Reflect.deleteProperty(sessionState, key);
vi.stubGlobal("chrome", {
storage: {
local: {
get: vi.fn(async (keys: readonly string[]) => select(localState, keys)),
set: vi.fn(async (values: Record<string, unknown>) =>
Object.assign(localState, values),
),
remove: vi.fn(async (keys: readonly string[]) => {
for (const key of keys) Reflect.deleteProperty(localState, key);
}),
},
session: {
get: vi.fn(async (keys: readonly string[]) => select(sessionState, keys)),
remove: vi.fn(async (keys: readonly string[]) => {
for (const key of keys) Reflect.deleteProperty(sessionState, key);
}),
},
},
});
});

describe("production storage compatibility", () => {
it("preserves an unknown experimental namespace byte-for-byte", async () => {
const experimental = { version: 2, nested: { keep: true } };
localState["pt.experimental.control-d.v2.config"] = experimental;

await migrateRetiredNamespace();

expect(localState["pt.experimental.control-d.v2.config"]).toBe(experimental);
expect(chrome.storage.local.remove).not.toHaveBeenCalledWith(
expect.arrayContaining(["pt.experimental.control-d.v2.config"]),
);
});
});
37 changes: 37 additions & 0 deletions src/background/storage/locations.target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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] = [
Expand Down
Loading
Loading