From 1b4e85ff017fd453f68e9c2782eeba7674a9ee85 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 20 Oct 2026 21:13:46 +0200 Subject: [PATCH 1/3] fix(desktop): timestamps follow the macOS region and Linux LC_TIME settings macOS reports a separate region as an ICU keyword (en_US@rg=nlzzzz) that Intl rejects, so the renderer fell back to en-US, and the macOS 12/24-hour switch never reached it. Linux took the locale from LANG instead of LC_TIME. The desktop now turns these into a tag Intl accepts, carrying the region and hour cycle. --- apps/desktop/src/electron/ElectronApp.test.ts | 42 +++++++++++++ apps/desktop/src/electron/ElectronApp.ts | 62 +++++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 84f37b6f5f537..b745e1d1c249b 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -78,6 +79,9 @@ vi.mock("electron", () => ({ whenReady: whenReadyMock, exit: exitMock, }, + systemPreferences: { + getUserDefault: vi.fn(() => false), + }, })); import * as ElectronApp from "./ElectronApp.ts"; @@ -128,6 +132,44 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it("keeps the macOS region override and hour cycle in the locale tag", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-US@rg=nlzzzz", "h12"), "en-NL-u-hc-h12"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_US@rg=nlzzzz"), "en-NL"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-GB", "h23"), "en-GB-u-hc-h23"); + }); + + it("drops ICU keywords Intl cannot read instead of losing the whole locale", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("de_DE@calendar=buddhist"), "de-DE"); + }); + + it.effect("follows LC_TIME on Linux when it differs from the language", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { + LANG: "en_US.UTF-8", + LC_TIME: "de_DE.UTF-8", + }), + ); + + assert.strictEqual(systemLocale, "de-DE"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("falls back to Chromium's locale on Linux when the time locale is C", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "fr-FR"); + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { LANG: "C.UTF-8" }), + ); + + assert.strictEqual(systemLocale, "fr-FR"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("reports which app metadata property failed", () => Effect.gen(function* () { const cause = new Error("version unavailable"); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0aae1148a4828..adda350fc8ab3 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -47,7 +48,9 @@ export class ElectronApp extends Context.Service< * The OS locale, read from the operating system rather than from Chromium's * resolved application locale — the packaged app ships only the `en-US` * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. + * pinned to `en-US` however the machine is configured. It carries the + * macOS region override and 12/24-hour preference, and the Linux LC_TIME + * locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; @@ -98,6 +101,48 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); +type SystemHourCycle = "h12" | "h23"; + +/** + * Turns an OS locale identifier into a tag `Intl` accepts. macOS reports ICU + * identifiers (`en_GB`, `en_US@rg=nlzzzz`) and Linux POSIX ones + * (`sr_RS@latin`), and `Intl` rejects both outright, which drops the renderer + * back to `en-US`. + */ +export function toSystemLocaleTag(identifier: string, hourCycle?: SystemHourCycle): string { + const [base = "", keywords = ""] = identifier.split("@"); + const languageTag = base.replace(/_/g, "-"); + // macOS writes `rg` when the region is set apart from the language, and the + // region picks the date formats. V8 ignores a `-u-rg` extension, so the + // override has to become the region subtag. + const region = /(?:^|;)rg=([a-z]{2}|\d{3})/i.exec(keywords)?.[1]?.toUpperCase(); + try { + return new Intl.Locale(languageTag, { + ...(region ? { region } : {}), + ...(hourCycle ? { hourCycle } : {}), + }).toString(); + } catch { + return languageTag; + } +} + +// The macOS 24-hour toggle lives outside the locale identifier. +function readMacHourCycle(): SystemHourCycle | undefined { + const prefs = Electron.systemPreferences; + if (prefs.getUserDefault("AppleICUForce24HourTime", "boolean")) return "h23"; + if (prefs.getUserDefault("AppleICUForce12HourTime", "boolean")) return "h12"; + return undefined; +} + +// Chromium reads the Linux locale from the language categories, but desktops +// set date formats apart from the language through LC_TIME. +function readLinuxTimeLocale(env: NodeJS.ProcessEnv): string | undefined { + // libc's precedence for the time category, where an empty value is unset. + const value = env.LC_ALL || env.LC_TIME || env.LANG; + const locale = value?.split(".")[0]; + return locale && locale !== "C" && locale !== "POSIX" ? locale : undefined; +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { @@ -127,10 +172,17 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. - systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), + systemLocale: Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + if (platform === "linux") { + const timeLocale = readLinuxTimeLocale(yield* HostProcess.Environment); + if (timeLocale) return toSystemLocaleTag(timeLocale); + } + return toSystemLocaleTag( + Electron.app.getSystemLocale(), + platform === "darwin" ? readMacHourCycle() : undefined, + ); + }), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ From 90c1b4884f81a18c9082770d4533fd7a344a4c67 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 20 Oct 2026 21:15:48 +0200 Subject: [PATCH 2/3] test(desktop): pin the platform in OS locale tests --- apps/desktop/src/electron/ElectronApp.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index b745e1d1c249b..877e0a2934e9a 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -92,6 +92,7 @@ describe("ElectronApp", () => { autoUpdaterOnMock.mockClear(); autoUpdaterRemoveListenerMock.mockClear(); exitMock.mockClear(); + getSystemLocaleMock.mockReset(); onMock.mockClear(); quitMock.mockClear(); relaunchMock.mockClear(); @@ -118,8 +119,11 @@ describe("ElectronApp", () => { it.effect("reads the OS locale through the service", () => Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "win32"), + ); - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + assert.strictEqual(systemLocale, "en-GB"); }).pipe(Effect.provide(ElectronApp.layer)), ); @@ -127,8 +131,11 @@ describe("ElectronApp", () => { Effect.gen(function* () { getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + ); - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + assert.strictEqual(systemLocale, "en-GB"); }).pipe(Effect.provide(ElectronApp.layer)), ); From 15e0cc9023d6e98f040dbfc8393c85975f60d9fe Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:16:43 +0000 Subject: [PATCH 3/3] test(desktop): cover system hour preferences and app overrides Co-authored-by: Illia Panasenko <576152+ipanasenko@users.noreply.github.com> --- apps/desktop/src/electron/ElectronApp.test.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index d783d59d2bce7..30b7f2d7387e3 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -3,8 +3,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { getSystemLocaleMock } = vi.hoisted(() => ({ +const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ getSystemLocaleMock: vi.fn(() => "en-GB"), + getUserDefaultMock: vi.fn((_key: string) => false), })); vi.mock("electron", () => ({ @@ -12,7 +13,7 @@ vi.mock("electron", () => ({ getSystemLocale: getSystemLocaleMock, }, systemPreferences: { - getUserDefault: vi.fn(() => false), + getUserDefault: getUserDefaultMock, }, })); @@ -21,6 +22,7 @@ import * as ElectronApp from "./ElectronApp.ts"; describe("ElectronApp", () => { beforeEach(() => { getSystemLocaleMock.mockReset(); + getUserDefaultMock.mockReset(); }); it("keeps the macOS region override and hour cycle in the locale tag", () => { @@ -62,3 +64,26 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); }); + +it.effect("reads both macOS hour preferences without overriding explicit formats", () => + Effect.gen(function* () { + for (const [preference, expected] of [ + ["AppleICUForce12HourTime", "en-NL-u-hc-h12"], + ["AppleICUForce24HourTime", "en-NL-u-hc-h23"], + ["", "en-NL"], + ]) { + getSystemLocaleMock.mockReturnValue("en_US@rg=nlzzzz"); + getUserDefaultMock.mockImplementation((key) => key === preference); + const locale = yield* ElectronApp.make.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + ); + assert.strictEqual(locale, expected); + for (const hour12 of [true, false]) { + assert.strictEqual( + new Intl.DateTimeFormat(locale, { hour: "numeric", hour12 }).resolvedOptions().hour12, + hour12, + ); + } + } + }), +);