| 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,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 40 | Layer.succeed(ElectronApp.ElectronApp, { | 40 | Layer.succeed(ElectronApp.ElectronApp, { |
| 41 | metadata: Effect.die("unexpected metadata read"), | 41 | metadata: Effect.die("unexpected metadata read"), |
| 42 | name: Effect.succeed("T3 Code"), | 42 | name: Effect.succeed("T3 Code"), |
| 43 | systemLocale: Effect.succeed("en-US"), | ||
| 43 | whenReady: Effect.void, | 44 | whenReady: Effect.void, |
| 44 | quit: Effect.void, | 45 | quit: Effect.void, |
| 45 | exit: () => Effect.void, | 46 | exit: () => Effect.void, |
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | @@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => { | ... | @@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => { |
| 21 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | 21 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 22 | metadata: Effect.die("unexpected metadata read"), | 22 | metadata: Effect.die("unexpected metadata read"), |
| 23 | name: Effect.succeed("T3 Code"), | 23 | name: Effect.succeed("T3 Code"), |
| 24 | systemLocale: Effect.succeed("en-US"), | ||
| 24 | whenReady: Effect.void, | 25 | whenReady: Effect.void, |
| 25 | quit: Effect.void, | 26 | quit: Effect.void, |
| 26 | exit: () => Effect.void, | 27 | exit: () => Effect.void, |
apps/desktop/src/electron/ElectronApp.test.ts+20| ... | @@ -8,6 +8,7 @@ const { | ... | @@ -8,6 +8,7 @@ const { |
| 8 | autoUpdaterRemoveListenerMock, | 8 | autoUpdaterRemoveListenerMock, |
| 9 | exitMock, | 9 | exitMock, |
| 10 | getAppPathMock, | 10 | getAppPathMock, |
| 11 | getSystemLocaleMock, | ||
| 11 | getVersionMock, | 12 | getVersionMock, |
| 12 | isDefaultProtocolClientMock, | 13 | isDefaultProtocolClientMock, |
| 13 | onMock, | 14 | onMock, |
| ... | @@ -29,6 +30,7 @@ const { | ... | @@ -29,6 +30,7 @@ const { |
| 29 | autoUpdaterRemoveListenerMock: vi.fn(), | 30 | autoUpdaterRemoveListenerMock: vi.fn(), |
| 30 | exitMock: vi.fn(), | 31 | exitMock: vi.fn(), |
| 31 | getAppPathMock: vi.fn(() => "/app"), | 32 | getAppPathMock: vi.fn(() => "/app"), |
| 33 | getSystemLocaleMock: vi.fn(() => "en-GB"), | ||
| 32 | getVersionMock: vi.fn(() => "1.2.3"), | 34 | getVersionMock: vi.fn(() => "1.2.3"), |
| 33 | isDefaultProtocolClientMock: vi.fn(() => false), | 35 | isDefaultProtocolClientMock: vi.fn(() => false), |
| 34 | onMock: vi.fn(), | 36 | onMock: vi.fn(), |
| ... | @@ -60,6 +62,7 @@ vi.mock("electron", () => ({ | ... | @@ -60,6 +62,7 @@ vi.mock("electron", () => ({ |
| 60 | setIcon: setDockIconMock, | 62 | setIcon: setDockIconMock, |
| 61 | }, | 63 | }, |
| 62 | getAppPath: getAppPathMock, | 64 | getAppPath: getAppPathMock, |
| 65 | getSystemLocale: getSystemLocaleMock, | ||
| 63 | getVersion: getVersionMock, | 66 | getVersion: getVersionMock, |
| 64 | isDefaultProtocolClient: isDefaultProtocolClientMock, | 67 | isDefaultProtocolClient: isDefaultProtocolClientMock, |
| 65 | isPackaged: true, | 68 | isPackaged: true, |
| ... | @@ -111,6 +114,23 @@ describe("ElectronApp", () => { | ... | @@ -111,6 +114,23 @@ describe("ElectronApp", () => { |
| 111 | }).pipe(Effect.provide(ElectronApp.layer)), | 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 | it.effect("reports which app metadata property failed", () => | 134 | it.effect("reports which app metadata property failed", () => |
| 115 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 116 | const cause = new Error("version unavailable"); | 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,6 +43,13 @@ export class ElectronApp extends Context.Service< |
| 43 | { | 43 | { |
| 44 | readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>; | 44 | readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>; |
| 45 | readonly name: Effect.Effect<string>; | 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 | readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>; | 53 | readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>; |
| 47 | readonly quit: Effect.Effect<void>; | 54 | readonly quit: Effect.Effect<void>; |
| 48 | readonly exit: (code: number) => Effect.Effect<void>; | 55 | readonly exit: (code: number) => Effect.Effect<void>; |
| ... | @@ -119,6 +126,10 @@ export const make = ElectronApp.of({ | ... | @@ -119,6 +126,10 @@ export const make = ElectronApp.of({ |
| 119 | }; | 126 | }; |
| 120 | }), | 127 | }), |
| 121 | name: Effect.sync(() => Electron.app.name), | 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 | whenReady: Effect.gen(function* () { | 133 | whenReady: Effect.gen(function* () { |
| 123 | const isPackaged = Electron.app.isPackaged; | 134 | const isPackaged = Electron.app.isPackaged; |
| 124 | yield* Effect.tryPromise({ | 135 | yield* Effect.tryPromise({ |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -34,6 +34,7 @@ import { | ... | @@ -34,6 +34,7 @@ import { |
| 34 | getAppBranding, | 34 | getAppBranding, |
| 35 | getLocalEnvironmentBootstraps, | 35 | getLocalEnvironmentBootstraps, |
| 36 | getLocalEnvironmentBearerToken, | 36 | getLocalEnvironmentBearerToken, |
| 37 | getSystemLocale, | ||
| 37 | getWindowFullscreenState, | 38 | getWindowFullscreenState, |
| 38 | openExternal, | 39 | openExternal, |
| 39 | probeRemoteEditors, | 40 | probeRemoteEditors, |
| ... | @@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 50 | yield* PreviewIpc.installPreviewEventForwarding(); | 51 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 51 | 52 | ||
| 52 | yield* ipc.handleSync(getAppBranding); | 53 | yield* ipc.handleSync(getAppBranding); |
| 54 | yield* ipc.handleSync(getSystemLocale); | ||
| 53 | yield* ipc.handleSync(getWindowFullscreenState); | 55 | yield* ipc.handleSync(getWindowFullscreenState); |
| 54 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); | 56 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); |
| 55 | yield* ipc.handle(getLocalEnvironmentBearerToken); | 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,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 15 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 15 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 16 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 16 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 17 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | 17 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 18 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; | ||
| 18 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; | 19 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; |
| 19 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = | 20 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = |
| 20 | "desktop:get-local-environment-bearer-token"; | 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,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; |
| 26 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | 26 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; |
| 27 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; | 27 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; |
| 28 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; | 28 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; |
| 29 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | ||
| 29 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | 30 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| 30 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; | 31 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; |
| 31 | import * as ElectronShell from "../../electron/ElectronShell.ts"; | 32 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| ... | @@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ | ... | @@ -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 | export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ | 77 | export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ |
| 68 | channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, | 78 | channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, |
| 69 | result: Schema.Boolean, | 79 | result: Schema.Boolean, |
apps/desktop/src/preload.ts+4| ... | @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | } | 35 | } |
| 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | 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 | getLocalEnvironmentBootstraps: () => { | 42 | getLocalEnvironmentBootstraps: () => { |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); | 43 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); |
| 40 | if (!Array.isArray(result)) { | 44 | if (!Array.isArray(result)) { |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1| ... | @@ -26,6 +26,7 @@ function makeElectronAppLayer( | ... | @@ -26,6 +26,7 @@ function makeElectronAppLayer( |
| 26 | return Layer.succeed(ElectronApp.ElectronApp, { | 26 | return Layer.succeed(ElectronApp.ElectronApp, { |
| 27 | metadata: Effect.die("unexpected metadata read"), | 27 | metadata: Effect.die("unexpected metadata read"), |
| 28 | name: Effect.succeed("T3 Code"), | 28 | name: Effect.succeed("T3 Code"), |
| 29 | systemLocale: Effect.succeed("en-US"), | ||
| 29 | whenReady: Effect.void, | 30 | whenReady: Effect.void, |
| 30 | quit: Effect.void, | 31 | quit: Effect.void, |
| 31 | exit: () => Effect.void, | 32 | exit: () => Effect.void, |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | @@ -31,6 +31,7 @@ const environmentInput = { | ... | @@ -31,6 +31,7 @@ const environmentInput = { |
| 31 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | 31 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 32 | metadata: Effect.die("unexpected metadata read"), | 32 | metadata: Effect.die("unexpected metadata read"), |
| 33 | name: Effect.succeed("T3 Code"), | 33 | name: Effect.succeed("T3 Code"), |
| 34 | systemLocale: Effect.succeed("en-US"), | ||
| 34 | whenReady: Effect.void, | 35 | whenReady: Effect.void, |
| 35 | quit: Effect.void, | 36 | quit: Effect.void, |
| 36 | exit: () => Effect.void, | 37 | exit: () => Effect.void, |
apps/web/src/timestampFormat.test.ts+35| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | formatTimestamp, | 12 | formatTimestamp, |
| 13 | getRelativeTimeState, | 13 | getRelativeTimeState, |
| 14 | getTimestampFormatOptions, | 14 | getTimestampFormatOptions, |
| 15 | resolveTimestampLocale, | ||
| 15 | } from "./timestampFormat"; | 16 | } from "./timestampFormat"; |
| 16 | 17 | ||
| 17 | describe("getTimestampFormatOptions", () => { | 18 | describe("getTimestampFormatOptions", () => { |
| ... | @@ -41,6 +42,40 @@ 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 | describe("formatRelativeTimeUntilLabel", () => { | 79 | describe("formatRelativeTimeUntilLabel", () => { |
| 45 | beforeEach(() => { | 80 | beforeEach(() => { |
| 46 | vi.useFakeTimers(); | 81 | vi.useFakeTimers(); |
apps/web/src/timestampFormat.ts+37-1| ... | @@ -20,6 +20,39 @@ export function getTimestampFormatOptions( | ... | @@ -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 | const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>(); | 56 | const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>(); |
| 24 | 57 | ||
| 25 | function getTimestampFormatter( | 58 | function getTimestampFormatter( |
| ... | @@ -33,7 +66,7 @@ function getTimestampFormatter( | ... | @@ -33,7 +66,7 @@ function getTimestampFormatter( |
| 33 | } | 66 | } |
| 34 | 67 | ||
| 35 | const formatter = new Intl.DateTimeFormat( | 68 | const formatter = new Intl.DateTimeFormat( |
| 36 | undefined, | 69 | timestampLocale, |
| 37 | getTimestampFormatOptions(timestampFormat, includeSeconds), | 70 | getTimestampFormatOptions(timestampFormat, includeSeconds), |
| 38 | ); | 71 | ); |
| 39 | timestampFormatterCache.set(cacheKey, formatter); | 72 | timestampFormatterCache.set(cacheKey, formatter); |
| ... | @@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma | ... | @@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma |
| 51 | return getTimestampFormatter(timestampFormat, true).format(date); | 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 | const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); | 90 | const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); |
| 55 | 91 | ||
| 56 | function ordinalSuffix(day: number): string { | 92 | function ordinalSuffix(day: number): string { |
packages/contracts/src/ipc.ts+7| ... | @@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ | ... | @@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ |
| 1021 | 1021 | ||
| 1022 | export interface DesktopBridge { | 1022 | export interface DesktopBridge { |
| 1023 | getAppBranding: () => DesktopAppBranding | null; | 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 | // One bootstrap per pool instance currently registered with bootstrap | 1031 | // One bootstrap per pool instance currently registered with bootstrap |
| 1025 | // info (omits instances whose backend hasn't produced a config yet). | 1032 | // info (omits instances whose backend hasn't produced a config yet). |
| 1026 | // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. | 1033 | // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. |