Skip to content
89 changes: 89 additions & 0 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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";

const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({
getSystemLocaleMock: vi.fn(() => "en-GB"),
getUserDefaultMock: vi.fn((_key: string) => false),
}));

vi.mock("electron", () => ({
app: {
getSystemLocale: getSystemLocaleMock,
},
systemPreferences: {
getUserDefault: getUserDefaultMock,
},
}));

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", () => {
assert.strictEqual(ElectronApp.toSystemLocaleTag("en_GB"), "en-GB");
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("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,
);
}
}
}),
);
35 changes: 33 additions & 2 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -93,6 +94,21 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
}),
).pipe(Effect.asVoid);

export function toSystemLocaleTag(identifier: string, hourCycle?: "h12" | "h23"): string {
const [base = "", keywords = ""] = identifier.split("@");
const languageTag = base.replace(/_/g, "-");
// Intl ignores rg extensions, so the macOS region override must replace the region subtag.
const region = /(?:^|;)rg=([a-z]{2}|\d{3})zzzz(?:;|$)/i.exec(keywords)?.[1]?.toUpperCase();
try {
return new Intl.Locale(languageTag, {
...(region ? { region } : {}),
...(hourCycle ? { hourCycle } : {}),
}).toString();
} catch {
return languageTag;
}
}

/** @public */
export const make = ElectronApp.of({
metadata: Effect.gen(function* () {
Expand Down Expand Up @@ -122,8 +138,23 @@ export const make = ElectronApp.of({
};
}),
name: Effect.sync(() => Electron.app.name),
// NSLocale returns en_GB-style tags; Intl requires hyphens.
systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")),
systemLocale: Effect.gen(function* () {
const platform = yield* HostProcess.Platform;
let identifier = Electron.app.getSystemLocale();
let hourCycle: "h12" | "h23" | undefined;
if (platform === "linux") {
const env = yield* HostProcess.Environment;
const timeLocale = (env.LC_ALL || env.LC_TIME || env.LANG)?.split(".")[0];
if (timeLocale && timeLocale !== "C" && timeLocale !== "POSIX") identifier = timeLocale;
} else if (platform === "darwin") {
if (Electron.systemPreferences.getUserDefault("AppleICUForce24HourTime", "boolean")) {
hourCycle = "h23";
} else if (Electron.systemPreferences.getUserDefault("AppleICUForce12HourTime", "boolean")) {
hourCycle = "h12";
}
}
return toSystemLocaleTag(identifier, hourCycle);
}),
whenReady: Effect.gen(function* () {
const isPackaged = Electron.app.isPackaged;
yield* Effect.tryPromise({
Expand Down
Loading