| author | |
| committer | |
| log | b72d5d79889ad9b4c022060be9eccdfa64733d18 |
| tree | 20ce98a99c4b77e8aee9f8299d17dace677a762d |
| parent | 31d0fb6ca7e31e5505d4e69063cb50d1e59ca1dc |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 131 insertions(+), 1 deletions(-)
apps/desktop/src/app/DesktopAppIdentity.test.ts+1| ... | ... | @@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 40 | 40 | Layer.succeed(ElectronApp.ElectronApp, { |
| 41 | 41 | metadata: Effect.die("unexpected metadata read"), |
| 42 | 42 | name: Effect.succeed("T3 Code"), |
| 43 | systemLocale: Effect.succeed("en-US"), | |
| 43 | 44 | whenReady: Effect.void, |
| 44 | 45 | quit: Effect.void, |
| 45 | 46 | exit: () => Effect.void, |
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | ... | @@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => { |
| 21 | 21 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 22 | 22 | metadata: Effect.die("unexpected metadata read"), |
| 23 | 23 | name: Effect.succeed("T3 Code"), |
| 24 | systemLocale: Effect.succeed("en-US"), | |
| 24 | 25 | whenReady: Effect.void, |
| 25 | 26 | quit: Effect.void, |
| 26 | 27 | exit: () => Effect.void, |
apps/desktop/src/electron/ElectronApp.test.ts+20| ... | ... | @@ -8,6 +8,7 @@ const { |
| 8 | 8 | autoUpdaterRemoveListenerMock, |
| 9 | 9 | exitMock, |
| 10 | 10 | getAppPathMock, |
| 11 | getSystemLocaleMock, | |
| 11 | 12 | getVersionMock, |
| 12 | 13 | isDefaultProtocolClientMock, |
| 13 | 14 | onMock, |
| ... | ... | @@ -29,6 +30,7 @@ const { |
| 29 | 30 | autoUpdaterRemoveListenerMock: vi.fn(), |
| 30 | 31 | exitMock: vi.fn(), |
| 31 | 32 | getAppPathMock: vi.fn(() => "/app"), |
| 33 | getSystemLocaleMock: vi.fn(() => "en-GB"), | |
| 32 | 34 | getVersionMock: vi.fn(() => "1.2.3"), |
| 33 | 35 | isDefaultProtocolClientMock: vi.fn(() => false), |
| 34 | 36 | onMock: vi.fn(), |
| ... | ... | @@ -60,6 +62,7 @@ vi.mock("electron", () => ({ |
| 60 | 62 | setIcon: setDockIconMock, |
| 61 | 63 | }, |
| 62 | 64 | getAppPath: getAppPathMock, |
| 65 | getSystemLocale: getSystemLocaleMock, | |
| 63 | 66 | getVersion: getVersionMock, |
| 64 | 67 | isDefaultProtocolClient: isDefaultProtocolClientMock, |
| 65 | 68 | isPackaged: true, |
| ... | ... | @@ -111,6 +114,23 @@ describe("ElectronApp", () => { |
| 111 | 114 | }).pipe(Effect.provide(ElectronApp.layer)), |
| 112 | 115 | ); |
| 113 | 116 | |
| 117 | it.effect("reads the OS locale through the service", () => | |
| 118 | Effect.gen(function* () { | |
| 119 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 120 | ||
| 121 | assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); | |
| 122 | }).pipe(Effect.provide(ElectronApp.layer)), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); | |
| 128 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 129 | ||
| 130 | assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); | |
| 131 | }).pipe(Effect.provide(ElectronApp.layer)), | |
| 132 | ); | |
| 133 | ||
| 114 | 134 | it.effect("reports which app metadata property failed", () => |
| 115 | 135 | Effect.gen(function* () { |
| 116 | 136 | const cause = new Error("version unavailable"); |
apps/desktop/src/electron/ElectronApp.ts+11| ... | ... | @@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service< |
| 43 | 43 | { |
| 44 | 44 | readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>; |
| 45 | 45 | readonly name: Effect.Effect<string>; |
| 46 | /** | |
| 47 | * The OS locale, read from the operating system rather than from Chromium's | |
| 48 | * resolved application locale — the packaged app ships only the `en-US` | |
| 49 | * locale pak, so `app.getLocale()` and the renderer's `Intl` default are | |
| 50 | * pinned to `en-US` however the machine is configured. | |
| 51 | */ | |
| 52 | readonly systemLocale: Effect.Effect<string>; | |
| 46 | 53 | readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>; |
| 47 | 54 | readonly quit: Effect.Effect<void>; |
| 48 | 55 | readonly exit: (code: number) => Effect.Effect<void>; |
| ... | ... | @@ -119,6 +126,10 @@ export const make = ElectronApp.of({ |
| 119 | 126 | }; |
| 120 | 127 | }), |
| 121 | 128 | name: Effect.sync(() => Electron.app.name), |
| 129 | // macOS derives this from NSLocale, which uses POSIX-style identifiers | |
| 130 | // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so | |
| 131 | // the tag is normalized here rather than in the renderer that consumes it. | |
| 132 | systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), | |
| 122 | 133 | whenReady: Effect.gen(function* () { |
| 123 | 134 | const isPackaged = Electron.app.isPackaged; |
| 124 | 135 | yield* Effect.tryPromise({ |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | getAppBranding, |
| 35 | 35 | getLocalEnvironmentBootstraps, |
| 36 | 36 | getLocalEnvironmentBearerToken, |
| 37 | getSystemLocale, | |
| 37 | 38 | getWindowFullscreenState, |
| 38 | 39 | openExternal, |
| 39 | 40 | probeRemoteEditors, |
| ... | ... | @@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 50 | 51 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 51 | 52 | |
| 52 | 53 | yield* ipc.handleSync(getAppBranding); |
| 54 | yield* ipc.handleSync(getSystemLocale); | |
| 53 | 55 | yield* ipc.handleSync(getWindowFullscreenState); |
| 54 | 56 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); |
| 55 | 57 | yield* ipc.handle(getLocalEnvironmentBearerToken); |
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -15,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 15 | 15 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 16 | 16 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 17 | 17 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 18 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; | |
| 18 | 19 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; |
| 19 | 20 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = |
| 20 | 21 | "desktop:get-local-environment-bearer-token"; |
apps/desktop/src/ipc/methods/window.ts+10| ... | ... | @@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; |
| 26 | 26 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; |
| 27 | 27 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; |
| 28 | 28 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; |
| 29 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | |
| 29 | 30 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| 30 | 31 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; |
| 31 | 32 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| ... | ... | @@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ |
| 64 | 65 | }), |
| 65 | 66 | }); |
| 66 | 67 | |
| 68 | export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ | |
| 69 | channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, | |
| 70 | result: Schema.String, | |
| 71 | handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () { | |
| 72 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 73 | return yield* electronApp.systemLocale; | |
| 74 | }), | |
| 75 | }); | |
| 76 | ||
| 67 | 77 | export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ |
| 68 | 78 | channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, |
| 69 | 79 | result: Schema.Boolean, |
apps/desktop/src/preload.ts+4| ... | ... | @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | 35 | } |
| 36 | 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 37 | 37 | }, |
| 38 | getSystemLocale: () => { | |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); | |
| 40 | return typeof result === "string" ? result : null; | |
| 41 | }, | |
| 38 | 42 | getLocalEnvironmentBootstraps: () => { |
| 39 | 43 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); |
| 40 | 44 | if (!Array.isArray(result)) { |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1| ... | ... | @@ -26,6 +26,7 @@ function makeElectronAppLayer( |
| 26 | 26 | return Layer.succeed(ElectronApp.ElectronApp, { |
| 27 | 27 | metadata: Effect.die("unexpected metadata read"), |
| 28 | 28 | name: Effect.succeed("T3 Code"), |
| 29 | systemLocale: Effect.succeed("en-US"), | |
| 29 | 30 | whenReady: Effect.void, |
| 30 | 31 | quit: Effect.void, |
| 31 | 32 | exit: () => Effect.void, |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | ... | @@ -31,6 +31,7 @@ const environmentInput = { |
| 31 | 31 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 32 | 32 | metadata: Effect.die("unexpected metadata read"), |
| 33 | 33 | name: Effect.succeed("T3 Code"), |
| 34 | systemLocale: Effect.succeed("en-US"), | |
| 34 | 35 | whenReady: Effect.void, |
| 35 | 36 | quit: Effect.void, |
| 36 | 37 | exit: () => Effect.void, |
apps/web/src/timestampFormat.test.ts+35| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | formatTimestamp, |
| 13 | 13 | getRelativeTimeState, |
| 14 | 14 | getTimestampFormatOptions, |
| 15 | resolveTimestampLocale, | |
| 15 | 16 | } from "./timestampFormat"; |
| 16 | 17 | |
| 17 | 18 | describe("getTimestampFormatOptions", () => { |
| ... | ... | @@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => { |
| 41 | 42 | }); |
| 42 | 43 | }); |
| 43 | 44 | |
| 45 | describe("resolveTimestampLocale", () => { | |
| 46 | it("defers to the runtime default when the host reports no locale", () => { | |
| 47 | expect(resolveTimestampLocale(null)).toBeUndefined(); | |
| 48 | expect(resolveTimestampLocale(undefined)).toBeUndefined(); | |
| 49 | expect(resolveTimestampLocale(" ")).toBeUndefined(); | |
| 50 | }); | |
| 51 | ||
| 52 | it("uses a BCP-47 tag reported by the host", () => { | |
| 53 | expect(resolveTimestampLocale("en-GB")).toBe("en-GB"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("defers to the runtime default rather than throwing on an unusable tag", () => { | |
| 57 | // The desktop bridge normalizes POSIX identifiers before reporting them, so | |
| 58 | // anything Intl still rejects here falls back instead of breaking every | |
| 59 | // timestamp in the UI. | |
| 60 | expect(resolveTimestampLocale("not a locale")).toBeUndefined(); | |
| 61 | expect(resolveTimestampLocale("en_GB")).toBeUndefined(); | |
| 62 | }); | |
| 63 | ||
| 64 | it("renders the host locale's hour cycle under the locale setting", () => { | |
| 65 | const formatAt1544 = (systemLocale: string | null) => | |
| 66 | new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { | |
| 67 | ...getTimestampFormatOptions("locale", false), | |
| 68 | timeZone: "UTC", | |
| 69 | }) | |
| 70 | .format(new Date("2026-04-07T15:44:00.000Z")) | |
| 71 | // ICU separates the day period with a narrow no-break space. | |
| 72 | .replace(/[  ]/g, " "); | |
| 73 | ||
| 74 | expect(formatAt1544("en-GB")).toBe("15:44"); | |
| 75 | expect(formatAt1544("en-US")).toBe("3:44 PM"); | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 44 | 79 | describe("formatRelativeTimeUntilLabel", () => { |
| 45 | 80 | beforeEach(() => { |
| 46 | 81 | vi.useFakeTimers(); |
apps/web/src/timestampFormat.ts+37-1| ... | ... | @@ -20,6 +20,39 @@ export function getTimestampFormatOptions( |
| 20 | 20 | }; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | /** | |
| 24 | * Pick the locale to format wall-clock times in, given the locale the host | |
| 25 | * reports. Hosts that report nothing fall back to `undefined`, which is the | |
| 26 | * runtime default and the right answer in a browser. | |
| 27 | * | |
| 28 | * A host reports a locale only when it knows better than the runtime does — | |
| 29 | * see `getSystemLocale` on the desktop bridge for why desktop does. | |
| 30 | */ | |
| 31 | export function resolveTimestampLocale( | |
| 32 | systemLocale: string | null | undefined, | |
| 33 | ): string | undefined { | |
| 34 | const tag = systemLocale?.trim(); | |
| 35 | if (!tag) return undefined; | |
| 36 | ||
| 37 | try { | |
| 38 | // Every timestamp in the UI runs through this formatter, so a tag the host | |
| 39 | // could not normalize falls back rather than throwing. Throws on a | |
| 40 | // structurally invalid tag; a well-formed tag ICU has no data for resolves | |
| 41 | // here and is left to ICU's own fallback. | |
| 42 | Intl.DateTimeFormat.supportedLocalesOf([tag]); | |
| 43 | return tag; | |
| 44 | } catch { | |
| 45 | return undefined; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | function readHostSystemLocale(): string | null { | |
| 50 | if (typeof window === "undefined") return null; | |
| 51 | return window.desktopBridge?.getSystemLocale?.() ?? null; | |
| 52 | } | |
| 53 | ||
| 54 | const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); | |
| 55 | ||
| 23 | 56 | const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>(); |
| 24 | 57 | |
| 25 | 58 | function getTimestampFormatter( |
| ... | ... | @@ -33,7 +66,7 @@ function getTimestampFormatter( |
| 33 | 66 | } |
| 34 | 67 | |
| 35 | 68 | const formatter = new Intl.DateTimeFormat( |
| 36 | undefined, | |
| 69 | timestampLocale, | |
| 37 | 70 | getTimestampFormatOptions(timestampFormat, includeSeconds), |
| 38 | 71 | ); |
| 39 | 72 | timestampFormatterCache.set(cacheKey, formatter); |
| ... | ... | @@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma |
| 51 | 84 | return getTimestampFormatter(timestampFormat, true).format(date); |
| 52 | 85 | } |
| 53 | 86 | |
| 87 | // Deliberately not the host locale: the tooltip's ordinal suffix and | |
| 88 | // day-before-month order below are English, so a localized month alone would | |
| 89 | // read "4th Juni 2026". Localizing the whole label is a separate change. | |
| 54 | 90 | const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); |
| 55 | 91 | |
| 56 | 92 | function ordinalSuffix(day: number): string { |
packages/contracts/src/ipc.ts+7| ... | ... | @@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ |
| 1021 | 1021 | |
| 1022 | 1022 | export interface DesktopBridge { |
| 1023 | 1023 | getAppBranding: () => DesktopAppBranding | null; |
| 1024 | /** | |
| 1025 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: | |
| 1026 | * the packaged app ships only the `en-US` Chromium locale pak, so | |
| 1027 | * `navigator.language` and the default `Intl` locale are pinned to `en-US` | |
| 1028 | * regardless of OS settings. | |
| 1029 | */ | |
| 1030 | getSystemLocale?: () => string | null; | |
| 1024 | 1031 | // One bootstrap per pool instance currently registered with bootstrap |
| 1025 | 1032 | // info (omits instances whose backend hasn't produced a config yet). |
| 1026 | 1033 | // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. |