Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The format is based on Keep a Changelog and the project follows Semantic Version

### Changed

- The interface language can be chosen in Settings. Automatic still follows the
browser language. Popup, sidebar, and Settings update without a reload.
- Experimental: every subframe in a tab now uses the top-frame runtime snapshot
(locale, timezone, fingerprint, geolocation) so iframe and worker realms do
not diverge from the page. Per-tab header rules apply to all requests in that
Expand Down
3 changes: 3 additions & 0 deletions src/background/runtime-refresh.target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ describe("shouldReloadRuntimeTabs", () => {
shouldReloadRuntimeTabs({
themeMode: "dark",
themeAccentPreset: "purple",
uiLocale: "es",
}),
).toBe(false);
expect(shouldSyncPreload({ uiLocale: "ru" })).toBe(false);
expect(shouldSyncHeaderRules({ uiLocale: "uk" })).toBe(false);
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/background/settings-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const persistImport = async (settings: ImportedSettings): Promise<void> => {
saveContainerAssignments(containerAssignments),
clearSiteSuggestions(),
savePreferences({
uiLocale: settings.uiLocale,
themeMode: settings.themeMode,
themeAccentPreset: settings.themeAccentPreset,
reduceMotion: settings.reduceMotion,
Expand Down Expand Up @@ -243,6 +244,7 @@ const buildImportResponse = (settings: ImportedSettings): ImportSettingsResponse
locations: settings.locations,
rules: settings.rules,
trustedSites: settings.trustedSites,
uiLocale: settings.uiLocale,
themeMode: settings.themeMode,
themeAccentPreset: settings.themeAccentPreset,
reduceMotion: settings.reduceMotion,
Expand Down
4 changes: 4 additions & 0 deletions src/background/settings-save-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
} from "@/shared/types";

type ResolvedSimpleSettings = {
nextUiLocale: Preferences["uiLocale"];
nextThemeMode: ThemeMode;
nextTrustedSites: TrustedSite[];
nextThemeAccentPreset: ThemeAccentPreset;
Expand Down Expand Up @@ -177,6 +178,7 @@ const resolveSettings = async (
loadCurrent: getGlobalFallbackRule,
});
return {
nextUiLocale: nextCommand.uiLocale ?? currentPreferences.uiLocale,
nextThemeMode,
nextTrustedSites,
nextThemeAccentPreset,
Expand Down Expand Up @@ -230,6 +232,7 @@ const buildPreferencesPatch = (
): Partial<Preferences> => {
const patch: Partial<Preferences> = {};
for (const key of [
"uiLocale",
"themeMode",
"themeAccentPreset",
"reduceMotion",
Expand Down Expand Up @@ -342,6 +345,7 @@ const cacheResolvedSettings = (

const buildSaveResponse = (settings: ResolvedSimpleSettings): SaveSettingsResponse => ({
ok: true,
uiLocale: settings.nextUiLocale,
themeMode: settings.nextThemeMode,
themeAccentPreset: settings.nextThemeAccentPreset,
reduceMotion: settings.nextReduceMotion,
Expand Down
11 changes: 9 additions & 2 deletions src/background/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import {
locationProfilesSchema,
} from "@/shared/profile-schema";
import { withAuthKey, withContainerSeed, withRuleSeedKey } from "@/shared/rule-seed";
import { normalizePreferences } from "@/shared/settings-defaults";
import { MAX_RANDOM_RADIUS_KM, MIN_RANDOM_RADIUS_KM } from "@/shared/settings-defaults";
import {
MAX_RANDOM_RADIUS_KM,
MIN_RANDOM_RADIUS_KM,
normalizePreferences,
} from "@/shared/settings-defaults";
import { DEFAULT_THEME_MODE } from "@/shared/theme-types";
import { DEFAULT_ACCENT_PRESET, THEME_ACCENT_PRESETS } from "@/shared/types";
import type {
Expand All @@ -33,6 +36,7 @@ import type {
ThemeAccentPreset,
ThemeMode,
} from "@/shared/types";
import { UI_LOCALE_PREFERENCES, type UiLocalePreference } from "@/shared/ui-locale";

const THEME_MODE_VALUES = ["light", "dark", "system"] as const;
const OSM_CONSENT_VALUES = ["unknown", "granted", "denied"] as const;
Expand All @@ -57,6 +61,7 @@ const normalizeLegacySpoofing = <

const settingsCommandSchema = z
.object({
uiLocale: z.enum(UI_LOCALE_PREFERENCES).optional(),
themeMode: z.enum(THEME_MODE_VALUES).optional(),
themeAccentPreset: z.enum(THEME_ACCENT_PRESETS).optional(),
reduceMotion: z.boolean().optional(),
Expand Down Expand Up @@ -258,6 +263,7 @@ export const validateImportedSettings = (
rules: DomainRule[];
trustedSites: TrustedSite[];
globalFallbackRule?: GlobalFallbackRule | undefined;
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand Down Expand Up @@ -323,6 +329,7 @@ export const validateImportedSettings = (
return {
...validated,
trustedSites,
uiLocale: preferences.uiLocale,
themeMode: sanitizeThemeMode(settings.themeMode),
themeAccentPreset: sanitizeAccent(settings.themeAccentPreset),
reduceMotion: preferences.reduceMotion,
Expand Down
33 changes: 9 additions & 24 deletions src/shared/extension-contract-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
XRaySurfaceCategory,
} from "./spoofing-surfaces.js";
import type { ThemeAccentPreset, ThemeMode } from "./theme-types.js";
import type { UiLocalePreference } from "./ui-locale.js";

export type ResolveSnapshotRequest = {
type: typeof EXTENSION_COMMAND_TYPES.resolveRuntimeSnapshot;
Expand All @@ -57,6 +58,7 @@ export type ExtensionCommand =
}
| {
type: typeof EXTENSION_COMMAND_TYPES.saveSimpleSettings;
uiLocale?: UiLocalePreference;
themeMode?: ThemeMode;
themeAccentPreset?: ThemeAccentPreset;
debugMode?: boolean;
Expand Down Expand Up @@ -400,6 +402,7 @@ export type GetSettingsResponse = {
rules: DomainRule[];
trustedSites: TrustedSite[];
globalFallbackRule?: GlobalFallbackRule | undefined;
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand All @@ -425,6 +428,7 @@ export type GetSettingsResponse = {
export type SaveSettingsResponse =
| {
ok: true;
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand Down Expand Up @@ -468,6 +472,7 @@ export type ResetSettingsResponse = {
rules: DomainRule[];
trustedSites: TrustedSite[];
globalFallbackRule?: GlobalFallbackRule | undefined;
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand Down Expand Up @@ -499,6 +504,7 @@ export type ImportSettingsResponse =
locations: Location[];
rules: DomainRule[];
trustedSites: TrustedSite[];
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand Down Expand Up @@ -594,34 +600,13 @@ export type UpdateRuleResponse =
};

export type ToggleRuleResponse =
| {
ok: true;
state: PopupState;
}
| {
ok: false;
error: string;
};
{ ok: true; state: PopupState } | { ok: false; error: string };

export type DeleteRuleResponse =
| {
ok: true;
state: PopupState;
}
| {
ok: false;
error: string;
};
{ ok: true; state: PopupState } | { ok: false; error: string };

export type ApplySuggestionResponse =
| {
ok: true;
state: PopupState;
}
| {
ok: false;
error: string;
};
{ ok: true; state: PopupState } | { ok: false; error: string };

export type LocationDraftResponse =
| {
Expand Down
6 changes: 6 additions & 0 deletions src/shared/settings-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ describe("normalizePreferences", () => {
expect(result).not.toHaveProperty("foo");
});

it("falls back to automatic when the interface language is invalid", () => {
expect(normalizePreferences({ uiLocale: "sepia" }).uiLocale).toBe("auto");
expect(normalizePreferences({}).uiLocale).toBe("auto");
expect(normalizePreferences({ uiLocale: "uk" }).uiLocale).toBe("uk");
});

it("rejects invalid theme and default radius values", () => {
const result = normalizePreferences({
themeMode: "sepia",
Expand Down
9 changes: 9 additions & 0 deletions src/shared/settings-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isThemeMode,
} from "@/shared/theme-types";
import type { OsmConsentState, ThemeAccentPreset, ThemeMode } from "@/shared/types";
import { isUiLocalePreference, type UiLocalePreference } from "@/shared/ui-locale";

/**
* Canonical scalar preferences for the extension.
Expand All @@ -37,6 +38,7 @@ export type Preferences = {
/** @deprecated Import/migration compatibility. Use sharedWorkerHandlingMode. */
sharedWorkerCompatibilityMode: boolean;
onboardingCompleted: boolean;
uiLocale: UiLocalePreference;
themeMode: ThemeMode;
themeAccentPreset: ThemeAccentPreset;
reduceMotion: boolean;
Expand Down Expand Up @@ -69,6 +71,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
sharedWorkerHandlingMode: "strict",
sharedWorkerCompatibilityMode: false,
onboardingCompleted: false,
uiLocale: "auto",
themeMode: DEFAULT_THEME_MODE,
themeAccentPreset: DEFAULT_ACCENT_PRESET,
reduceMotion: false,
Expand Down Expand Up @@ -96,6 +99,11 @@ const isWatchPositionDelay = (value: unknown): value is [number, number] =>
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};

const readUiLocale = (source: Record<string, unknown>): UiLocalePreference =>
isUiLocalePreference(source.uiLocale)
? source.uiLocale
: DEFAULT_PREFERENCES.uiLocale;

/**
* Fills a partial/unknown preferences value with canonical defaults, validating
* each field. The result is always a complete, well-typed {@link Preferences}.
Expand Down Expand Up @@ -133,6 +141,7 @@ export const normalizePreferences = (raw: unknown): Preferences => {
typeof source.onboardingCompleted === "boolean"
? source.onboardingCompleted
: DEFAULT_PREFERENCES.onboardingCompleted,
uiLocale: readUiLocale(source),
themeMode: isThemeMode(source.themeMode)
? source.themeMode
: DEFAULT_PREFERENCES.themeMode,
Expand Down
2 changes: 2 additions & 0 deletions src/shared/shared-model-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import type {
import { XRAY_SURFACE_CATEGORIES } from "./spoofing-surfaces.js";
import { DEFAULT_ACCENT_PRESET, THEME_ACCENT_PRESETS } from "./theme-types.js";
import type { ThemeAccentPreset, ThemeMode } from "./theme-types.js";
import type { UiLocalePreference } from "./ui-locale.js";

export type {
BrowserClientHintBrand,
Expand Down Expand Up @@ -327,6 +328,7 @@ export type ExportedSettings = {
rules: DomainRule[];
trustedSites?: TrustedSite[];
globalFallbackRule?: GlobalFallbackRule | undefined;
uiLocale?: UiLocalePreference;
themeMode?: ThemeMode;
themeAccentPreset?: ThemeAccentPreset;
reduceMotion?: boolean;
Expand Down
14 changes: 14 additions & 0 deletions src/shared/ui-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const UI_LOCALE_PREFERENCES = ["auto", "en", "es", "pt", "ru", "uk"] as const;

export type UiLocalePreference = (typeof UI_LOCALE_PREFERENCES)[number];

export const UI_LOCALES = ["en", "es", "pt", "ru", "uk"] as const;

export type UiLocale = (typeof UI_LOCALES)[number];

export const isUiLocalePreference = (value: unknown): value is UiLocalePreference =>
typeof value === "string" &&
(UI_LOCALE_PREFERENCES as readonly string[]).includes(value);

export const isUiLocale = (value: string): value is UiLocale =>
(UI_LOCALES as readonly string[]).includes(value);
16 changes: 16 additions & 0 deletions src/ui/i18n/LocaleRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useSyncExternalStore, type ReactNode } from "react";

import { getActiveUiLocale, subscribeActiveUiLocale } from "@/ui/i18n";
import type { UiLocale } from "@/ui/i18n";

export const useUiLocale = (): UiLocale =>
useSyncExternalStore(subscribeActiveUiLocale, getActiveUiLocale, getActiveUiLocale);

export const LocaleRefresh = ({ children }: { children: ReactNode }) => {
const locale = useUiLocale();
return (
<div key={locale} style={{ display: "contents" }}>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{children}
</div>
);
};
5 changes: 3 additions & 2 deletions src/ui/i18n/en-sections/advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,14 @@ export const advanced = {

language: {
title: "Language",
description: `${BRAND_DISPLAY_NAME} automatically follows your browser interface language.`,
description: `${BRAND_DISPLAY_NAME} follows your browser language on Automatic. Choose a language to use that language instead.`,
optionAutomatic: "Automatic",
optionEnglish: "English",
optionSpanish: "Spanish",
optionPortuguese: "Portuguese",
optionRussian: "Russian",
optionUkrainian: "Ukrainian",
soon: "AUTO",
reportLabel: "Found a translation error? Report it",
copyLinkLabel: "language",
},

Expand Down
5 changes: 3 additions & 2 deletions src/ui/i18n/es-sections/advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,14 @@ export const advanced = {

language: {
title: "Idioma",
description: `${BRAND_DISPLAY_NAME} usa automáticamente el idioma de la interfaz del navegador.`,
description: `${BRAND_DISPLAY_NAME} sigue el idioma del navegador en Automático. Elige un idioma para usarlo en su lugar.`,
optionAutomatic: "Automático",
optionEnglish: "Inglés",
optionSpanish: "Español",
optionPortuguese: "Portugués",
optionRussian: "Ruso",
optionUkrainian: "Ucraniano",
soon: "AUTOMÁTICO",
reportLabel: "¿Encontraste un error de traducción? Infórmalo",
copyLinkLabel: "idioma",
},

Expand Down
10 changes: 9 additions & 1 deletion src/ui/i18n/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { resolveUiLocale } from "./index";
import { applyUiLocalePreference, resolveUiLocale, t } from "./index";

describe("resolveUiLocale", () => {
it.each(["es", "es-ES", "es-MX", "ES-ar"])("selects Spanish for %s", (language) => {
Expand Down Expand Up @@ -28,4 +28,12 @@ describe("resolveUiLocale", () => {
expect(resolveUiLocale(language)).toBe("en");
},
);

it("switches the active catalog without recapturing nested messages", () => {
const language = t.advanced.display.language;
applyUiLocalePreference("es");
expect(language.title).toBe("Idioma");
applyUiLocalePreference("en");
expect(language.title).toBe("Language");
});
});
Loading