diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..44478928601e 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + preferredSystemLanguages: Effect.succeed(["en-US"]), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af6..1c25162e5f5a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -33,6 +33,7 @@ function makeElectronAppLayer( return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + preferredSystemLanguages: Effect.succeed(["en-US"]), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 4189ea793e2d..4aebc668b867 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -8,6 +8,7 @@ const { autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, + getPreferredSystemLanguagesMock, getSystemLocaleMock, getVersionMock, isDefaultProtocolClientMock, @@ -30,6 +31,7 @@ const { autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), + getPreferredSystemLanguagesMock: vi.fn(() => ["pt_BR", "en-US"]), getSystemLocaleMock: vi.fn(() => "en-GB"), getVersionMock: vi.fn(() => "1.2.3"), isDefaultProtocolClientMock: vi.fn(() => false), @@ -62,6 +64,7 @@ vi.mock("electron", () => ({ setIcon: setDockIconMock, }, getAppPath: getAppPathMock, + getPreferredSystemLanguages: getPreferredSystemLanguagesMock, getSystemLocale: getSystemLocaleMock, getVersion: getVersionMock, isDefaultProtocolClient: isDefaultProtocolClientMock, @@ -122,6 +125,14 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reads and normalizes the OS preferred languages", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + + assert.deepEqual(yield* electronApp.preferredSystemLanguages, ["pt-BR", "en-US"]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => Effect.gen(function* () { getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5a6f16ae89fd..ce873c99758a 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -50,6 +50,11 @@ export class ElectronApp extends Context.Service< * pinned to `en-US` however the machine is configured. */ readonly systemLocale: Effect.Effect; + /** + * The user's ordered OS language preferences, distinct from the regional + * locale. Keyboard input methods are discovered separately. + */ + readonly preferredSystemLanguages: Effect.Effect; readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; @@ -130,6 +135,9 @@ export const make = ElectronApp.of({ // (`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, "-")), + preferredSystemLanguages: Effect.sync(() => + Electron.app.getPreferredSystemLanguages().map((language) => language.replace(/_/g, "-")), + ), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/electron/ElectronSpellcheck.test.ts b/apps/desktop/src/electron/ElectronSpellcheck.test.ts new file mode 100644 index 000000000000..49158a615e86 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSpellcheck.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applySpellCheckerSession, + keyboardLayoutsFromConfig, + keyboardLayoutsFromEnvironment, + matchAvailableSpellcheckLanguage, + normalizeLocaleTag, + parseXkbLayouts, + preferredSpellcheckLanguages, + resolveSpellCheckerLanguages, + spellcheckSettingsEqual, + windowsKeyboardLanguageTagsFromOutput, +} from "./ElectronSpellcheck.ts"; + +const AVAILABLE = [ + "en-US", + "en-GB", + "en-AU", + "en-CA", + "pt-BR", + "pt-PT", + "es", + "es-419", + "es-ES", + "fr", + "de-DE", + "it", + "nl", + "pl", + "ru", +] as const; + +describe("normalizeLocaleTag", () => { + it("turns POSIX locale identifiers into BCP-47 tags", () => { + expect(normalizeLocaleTag("pt_BR.UTF-8")).toBe("pt-BR"); + expect(normalizeLocaleTag("en_US.utf8@euro")).toBe("en-US"); + expect(normalizeLocaleTag("C")).toBeUndefined(); + expect(normalizeLocaleTag("POSIX")).toBeUndefined(); + expect(normalizeLocaleTag("")).toBeUndefined(); + }); +}); + +describe("parseXkbLayouts", () => { + it("splits layout lists and preserves inline or paired variants", () => { + expect(parseXkbLayouts("us,br", ",abnt2")).toEqual([ + { layout: "us" }, + { layout: "br", variant: "abnt2" }, + ]); + expect(parseXkbLayouts("ch(fr)")).toEqual([{ layout: "ch", variant: "fr" }]); + expect(parseXkbLayouts("us+intl")).toEqual([{ layout: "us", variant: "intl" }]); + }); +}); + +describe("keyboard layout discovery", () => { + it("reads vconsole and Debian keyboard assignments with shell quoting and comments", () => { + expect( + keyboardLayoutsFromConfig( + [ + "# comment", + "XKBLAYOUT='ch,ca' # active layouts", + 'XKBVARIANT="fr,fr-dvorak"', + "KEYMAP='br-abnt2' # console layout", + ].join("\n"), + ), + ).toEqual([ + { layout: "ch", variant: "fr" }, + { layout: "ca", variant: "fr-dvorak" }, + { layout: "br", variant: "abnt2" }, + ]); + }); + + it("pairs XKB environment layouts with their variants", () => { + expect( + keyboardLayoutsFromEnvironment({ + XKB_DEFAULT_LAYOUT: "us,ch", + XKB_DEFAULT_VARIANT: ",fr", + }), + ).toEqual([{ layout: "us" }, { layout: "ch", variant: "fr" }]); + }); + + it("normalizes and deduplicates Windows keyboard layout query output", () => { + expect(windowsKeyboardLanguageTagsFromOutput("pt_BR\r\nen-US\r\npt-BR\r\n")).toEqual([ + "pt-BR", + "en-US", + ]); + }); +}); + +describe("matchAvailableSpellcheckLanguage", () => { + it("prefers an exact Hunspell dictionary", () => { + expect(matchAvailableSpellcheckLanguage("pt-BR", AVAILABLE)).toBe("pt-BR"); + expect(matchAvailableSpellcheckLanguage("fr-FR", AVAILABLE)).toBe("fr"); + expect(matchAvailableSpellcheckLanguage("de", AVAILABLE)).toBe("de-DE"); + expect(matchAvailableSpellcheckLanguage("en", AVAILABLE)).toBe("en-US"); + expect(matchAvailableSpellcheckLanguage("ja", AVAILABLE)).toBeUndefined(); + }); +}); + +describe("preferredSpellcheckLanguages", () => { + it("uses an explicit language list when the user picked dictionaries", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "en-US", + env: { XKBLAYOUT: "br", LANG: "en_US.UTF-8" }, + configuredLanguages: ["pt-BR"], + }), + ).toEqual(["pt-BR"]); + }); + + it("adds the keyboard layout when the OS locale is English", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "en-US", + env: { LANG: "en_US.UTF-8", XKBLAYOUT: "br" }, + configuredLanguages: [], + }), + ).toEqual(["pt-BR", "en-US"]); + }); + + it("reads both Linux keyboard configuration formats when the environment has no layout", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "en-US", + env: { LANG: "en_US.UTF-8" }, + configuredLanguages: [], + keyboardConfigs: ["KEYMAP=us\n", 'XKBLAYOUT="br"\n'], + }), + ).toEqual(["en-US", "pt-BR"]); + }); + + it("uses language-bearing XKB variants before the base layout", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "de-CH", + env: { XKB_DEFAULT_LAYOUT: "ch,ca", XKB_DEFAULT_VARIANT: "fr,fr-dvorak" }, + configuredLanguages: [], + }), + ).toEqual(["fr", "de-CH"]); + }); + + it("prioritizes platform keyboard layouts over OS preferred languages", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "en-US", + preferredSystemLanguages: ["en-US"], + platformKeyboardLanguages: ["pt_BR"], + env: {}, + configuredLanguages: [], + }), + ).toEqual(["pt-BR", "en-US"]); + }); + + it("honors LANGUAGE before LANG", () => { + expect( + preferredSpellcheckLanguages({ + systemLocale: "en-US", + env: { LANGUAGE: "pt_BR:en", LANG: "en_US.UTF-8" }, + configuredLanguages: [], + }), + ).toEqual(["en-US", "pt-BR", "en"]); + }); +}); + +describe("resolveSpellCheckerLanguages", () => { + it("drops tags Chromium cannot download and keeps order", () => { + expect( + resolveSpellCheckerLanguages({ + available: AVAILABLE, + preferred: ["en-US", "pt-BR", "ja", "en-US"], + }), + ).toEqual(["en-US", "pt-BR"]); + }); + + it("does not invent en-US when nothing matches", () => { + expect( + resolveSpellCheckerLanguages({ + available: AVAILABLE, + preferred: ["ja", "zh-CN"], + }), + ).toEqual([]); + }); + + it("does not substitute another regional dictionary for an explicit selection", () => { + expect( + resolveSpellCheckerLanguages({ + available: ["en-US"], + preferred: ["en-GB"], + requireExact: true, + }), + ).toEqual([]); + }); + + it("does not truncate a user-selected language list", () => { + const preferred = AVAILABLE.slice(0, 12); + expect(resolveSpellCheckerLanguages({ available: AVAILABLE, preferred })).toEqual(preferred); + }); +}); + +describe("spellcheckSettingsEqual", () => { + it("only considers the spellcheck settings and preserves language order", () => { + const defaults = { spellcheckEnabled: true, spellcheckLanguages: ["en-US", "pt-BR"] }; + expect(spellcheckSettingsEqual(defaults, { ...defaults })).toBe(true); + expect( + spellcheckSettingsEqual(defaults, { + spellcheckEnabled: true, + spellcheckLanguages: ["pt-BR", "en-US"], + }), + ).toBe(false); + expect( + spellcheckSettingsEqual(defaults, { + spellcheckEnabled: false, + spellcheckLanguages: defaults.spellcheckLanguages, + }), + ).toBe(false); + }); +}); + +describe("applySpellCheckerSession", () => { + it("enables Hunspell for the OS locale and keyboard", () => { + const setSpellCheckerLanguages = (languages: string[]) => { + applied = languages; + }; + let enabled: boolean | undefined; + let applied: string[] | undefined; + const session = { + availableSpellCheckerLanguages: AVAILABLE, + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerLanguages, + setSpellCheckerEnabled: (value: boolean) => { + enabled = value; + }, + }; + + const result = applySpellCheckerSession(session, { + enabled: true, + platform: "linux", + configuredLanguages: [], + systemLocale: "en-US", + env: { LANG: "en_US.UTF-8", XKBLAYOUT: "br" }, + }); + + expect(enabled).toBe(true); + expect(result).toEqual({ enabled: true, languages: ["pt-BR", "en-US"] }); + expect(applied).toEqual(["pt-BR", "en-US"]); + }); + + it("leaves the current dictionaries alone when turning spellcheck off", () => { + const setSpellCheckerLanguages = () => { + throw new Error("should not rewrite dictionaries while disabled"); + }; + let enabled: boolean | undefined; + const session = { + availableSpellCheckerLanguages: AVAILABLE, + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerLanguages, + setSpellCheckerEnabled: (value: boolean) => { + enabled = value; + }, + }; + + expect( + applySpellCheckerSession(session, { + enabled: false, + platform: "linux", + configuredLanguages: ["pt-BR"], + systemLocale: "en-US", + env: {}, + }), + ).toEqual({ enabled: false, languages: [] }); + expect(enabled).toBe(false); + }); + + it("disables Hunspell instead of allowing Electron's en-US fallback", () => { + const setSpellCheckerLanguages = () => { + throw new Error("empty language lists fall back to en-US in Electron"); + }; + let enabled: boolean | undefined; + const session = { + availableSpellCheckerLanguages: AVAILABLE, + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerLanguages, + setSpellCheckerEnabled: (value: boolean) => { + enabled = value; + }, + }; + + expect( + applySpellCheckerSession(session, { + enabled: true, + platform: "linux", + configuredLanguages: [], + systemLocale: "C", + env: {}, + }), + ).toEqual({ enabled: false, languages: [] }); + expect(enabled).toBe(false); + }); + + it("disables Hunspell when an explicit dictionary is unavailable", () => { + let enabled: boolean | undefined; + const session = { + availableSpellCheckerLanguages: ["en-US"], + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerLanguages: () => { + throw new Error("must not substitute en-US for en-GB"); + }, + setSpellCheckerEnabled: (value: boolean) => { + enabled = value; + }, + }; + + expect( + applySpellCheckerSession(session, { + enabled: true, + platform: "linux", + configuredLanguages: ["en-GB"], + systemLocale: "en-US", + env: {}, + }), + ).toEqual({ enabled: false, languages: [] }); + expect(enabled).toBe(false); + }); + + it("keeps the fallback disabled when replacing dictionaries fails", () => { + const enabledCalls: boolean[] = []; + const session = { + availableSpellCheckerLanguages: AVAILABLE, + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerLanguages: () => { + throw new Error("dictionary update failed"); + }, + setSpellCheckerEnabled: (value: boolean) => { + enabledCalls.push(value); + }, + }; + + expect(() => + applySpellCheckerSession(session, { + enabled: true, + platform: "linux", + configuredLanguages: ["pt-BR"], + systemLocale: "", + env: {}, + }), + ).toThrow("dictionary update failed"); + expect(enabledCalls).toEqual([false]); + }); + + it("lets the native macOS checker choose its own languages", () => { + let enabled: boolean | undefined; + const session = { + availableSpellCheckerLanguages: AVAILABLE, + getSpellCheckerLanguages: () => ["pt-BR", "en-US"], + setSpellCheckerLanguages: () => { + throw new Error("setSpellCheckerLanguages is a no-op on macOS"); + }, + setSpellCheckerEnabled: (value: boolean) => { + enabled = value; + }, + }; + + expect( + applySpellCheckerSession(session, { + enabled: true, + platform: "darwin", + configuredLanguages: ["fr"], + systemLocale: "", + env: {}, + }), + ).toEqual({ enabled: true, languages: ["pt-BR", "en-US"] }); + expect(enabled).toBe(true); + }); +}); diff --git a/apps/desktop/src/electron/ElectronSpellcheck.ts b/apps/desktop/src/electron/ElectronSpellcheck.ts new file mode 100644 index 000000000000..02a10e21ab89 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSpellcheck.ts @@ -0,0 +1,492 @@ +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as Electron from "electron"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "./ElectronApp.ts"; + +const { logWarning } = makeComponentLogger("desktop-spellcheck"); + +const LINUX_KEYBOARD_CONFIG_PATHS = ["/etc/vconsole.conf", "/etc/default/keyboard"] as const; +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const WINDOWS_KEYBOARD_QUERY_TIMEOUT = Duration.seconds(3); +const spellcheckerSyncSemaphore = Semaphore.makeUnsafe(1); +const WINDOWS_KEYBOARD_LANGUAGE_SCRIPT = [ + "try {", + 'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class T3KeyboardLayout { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr processId); [DllImport("user32.dll")] public static extern IntPtr GetKeyboardLayout(uint threadId); }\' -ErrorAction Stop;', + "$window = [T3KeyboardLayout]::GetForegroundWindow();", + "$thread = [T3KeyboardLayout]::GetWindowThreadProcessId($window, [IntPtr]::Zero);", + "$layout = [T3KeyboardLayout]::GetKeyboardLayout($thread).ToInt64();", + "$activeLangId = [int]($layout -band 0xffff);", + "[Globalization.CultureInfo]::GetCultureInfo($activeLangId).Name", + "} catch {}", + "Get-WinUserLanguageList | ForEach-Object { $_.InputMethodTips } | ForEach-Object {", + "try {", + "$languageId = ($_ -split ':')[0];", + "if ($languageId -match '^[0-9a-fA-F]{4}$') {", + "$langId = [Convert]::ToInt32($languageId, 16);", + "[Globalization.CultureInfo]::GetCultureInfo($langId).Name", + "}", + "} catch {}", + "}", +].join(" "); + +const XKB_LAYOUT_TO_SPELLCHECK: Readonly> = { + us: "en-US", + gb: "en-GB", + uk: "en-GB", + ie: "en-GB", + au: "en-AU", + ca: "en-CA", + br: "pt-BR", + pt: "pt-PT", + es: "es-ES", + latam: "es-419", + fr: "fr", + be: "fr", + de: "de-DE", + at: "de-DE", + ch: "de-DE", + it: "it", + ru: "ru", + ua: "uk", + pl: "pl", + cz: "cs", + sk: "sk", + hu: "hu", + ro: "ro", + nl: "nl", + tr: "tr", + se: "sv", + dk: "da", + no: "nb", + fi: "fi", + gr: "el", + il: "he", + hr: "hr", + si: "sl", + bg: "bg", + ee: "et", + lv: "lv", + lt: "lt", +}; + +export interface SpellCheckerSession { + readonly availableSpellCheckerLanguages: readonly string[]; + getSpellCheckerLanguages(): string[]; + setSpellCheckerLanguages(languages: string[]): void; + setSpellCheckerEnabled(enabled: boolean): void; +} + +export interface XkbLayoutPreference { + readonly layout: string; + readonly variant?: string; +} + +type SpellcheckSettings = Pick; + +export class ElectronSpellcheckApplyError extends Schema.TaggedErrorClass()( + "ElectronSpellcheckApplyError", + { + enabled: Schema.Boolean, + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to apply the composer spell checker (enabled: ${this.enabled}, platform: ${this.platform}).`; + } +} + +export function normalizeLocaleTag(raw: string): string | undefined { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return undefined; + } + const withoutModifier = trimmed.split("@")[0] ?? trimmed; + const withoutCharset = withoutModifier.split(".")[0] ?? withoutModifier; + const tag = withoutCharset.replaceAll("_", "-"); + if (tag.length === 0 || tag === "C" || tag === "POSIX") { + return undefined; + } + return tag; +} + +function splitXkbValues(raw: string): string[] { + return raw.includes(",") ? raw.split(",") : raw.trim().split(/\s+/); +} + +export function parseXkbLayouts(rawLayouts: string, rawVariants = ""): XkbLayoutPreference[] { + const variants = rawVariants.split(","); + const layouts: XkbLayoutPreference[] = []; + for (const [index, part] of splitXkbValues(rawLayouts).entries()) { + const trimmed = part.trim().toLowerCase(); + if (trimmed.length === 0) continue; + const combined = /^([^+(:]+)(?:[+(:]([^)]*)\)?)?$/.exec(trimmed); + const layout = (combined?.[1] ?? trimmed).trim(); + const variant = (combined?.[2] ?? variants[index] ?? "").trim().toLowerCase(); + if (layout.length === 0) continue; + layouts.push({ layout, ...(variant.length > 0 ? { variant } : {}) }); + } + return layouts; +} + +function parseKeyboardConfigValue(raw: string): string { + return raw + .trim() + .replace(/\s+#.*$/, "") + .trim() + .replace(/^(['"])(.*)\1$/, "$2"); +} + +export function keyboardLayoutsFromConfig(contents: string): XkbLayoutPreference[] { + const assignments = new Map(); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const separator = trimmed.indexOf("="); + if (separator < 0) continue; + assignments.set( + trimmed.slice(0, separator).trim(), + parseKeyboardConfigValue(trimmed.slice(separator + 1)), + ); + } + + const layouts = parseXkbLayouts( + assignments.get("XKBLAYOUT") ?? "", + assignments.get("XKBVARIANT") ?? "", + ); + const keymap = assignments.get("KEYMAP")?.toLowerCase().split(".")[0]; + if (keymap !== undefined && keymap.length > 0) { + const [layout, ...variantParts] = keymap.split("-"); + if (layout !== undefined && layout.length > 0) { + const variant = variantParts.join("-"); + layouts.push({ layout, ...(variant.length > 0 ? { variant } : {}) }); + } + } + return layouts; +} + +export function keyboardLayoutsFromEnvironment(env: NodeJS.Dict): XkbLayoutPreference[] { + return [ + ...parseXkbLayouts(env.XKBLAYOUT ?? "", env.XKBVARIANT ?? ""), + ...parseXkbLayouts(env.XKB_DEFAULT_LAYOUT ?? "", env.XKB_DEFAULT_VARIANT ?? ""), + ]; +} + +function spellcheckLanguageForKeyboardLayout(preference: XkbLayoutPreference): string | undefined { + const variantLanguage = preference.variant?.split(/[-_]/)[0]; + return ( + (variantLanguage === undefined ? undefined : XKB_LAYOUT_TO_SPELLCHECK[variantLanguage]) ?? + XKB_LAYOUT_TO_SPELLCHECK[preference.layout] + ); +} + +export function localeTagsFromEnvironment(env: NodeJS.Dict): string[] { + const tags: string[] = []; + const language = env.LANGUAGE; + if (language !== undefined && language.length > 0) { + for (const part of language.split(":")) { + const tag = normalizeLocaleTag(part); + if (tag !== undefined) { + tags.push(tag); + } + } + } + for (const key of ["LC_ALL", "LC_MESSAGES", "LANG"] as const) { + const tag = normalizeLocaleTag(env[key] ?? ""); + if (tag !== undefined) { + tags.push(tag); + } + } + return tags; +} + +export function windowsKeyboardLanguageTagsFromOutput(raw: string): string[] { + const tags: string[] = []; + for (const line of raw.split(/\r?\n/)) { + const tag = normalizeLocaleTag(line); + if (tag !== undefined) tags.push(tag); + } + return uniquePreferredLanguages(tags); +} + +export const readWindowsKeyboardLanguageTags = Effect.fn( + "desktop.spellcheck.readWindowsKeyboardLanguageTags", +)(function* (): Effect.fn.Return< + readonly string[], + never, + ChildProcessSpawner.ChildProcessSpawner +> { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner + .string( + ChildProcess.make( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_KEYBOARD_LANGUAGE_SCRIPT], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ), + ) + .pipe( + Effect.timeoutOption(WINDOWS_KEYBOARD_QUERY_TIMEOUT), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.match(output, { + onNone: () => [], + onSome: windowsKeyboardLanguageTagsFromOutput, + }); +}); + +export function matchAvailableSpellcheckLanguage( + preferred: string, + available: readonly string[], +): string | undefined { + if (available.length === 0) { + return undefined; + } + const availableByLower = new Map(available.map((language) => [language.toLowerCase(), language])); + const preferredLower = preferred.toLowerCase(); + const exact = availableByLower.get(preferredLower); + if (exact !== undefined) { + return exact; + } + const language = preferredLower.split("-")[0] ?? preferredLower; + if (preferredLower.includes("-")) { + const languageOnly = availableByLower.get(language); + if (languageOnly !== undefined) { + return languageOnly; + } + } + const regional = available.filter( + (candidate) => + candidate.toLowerCase() === language || candidate.toLowerCase().startsWith(`${language}-`), + ); + if (regional.length === 0) { + return undefined; + } + const us = regional.find((candidate) => candidate.toLowerCase() === `${language}-us`); + if (us !== undefined) { + return us; + } + const bare = regional.find((candidate) => candidate.toLowerCase() === language); + if (bare !== undefined) { + return bare; + } + return regional[0]; +} + +export function resolveSpellCheckerLanguages(input: { + readonly available: readonly string[]; + readonly preferred: readonly string[]; + readonly requireExact?: boolean; +}): string[] { + const resolved: string[] = []; + const seen = new Set(); + const availableByLower = input.requireExact + ? new Map(input.available.map((language) => [language.toLowerCase(), language])) + : undefined; + for (const preferred of input.preferred) { + const matched = + availableByLower?.get(preferred.toLowerCase()) ?? + (input.requireExact + ? undefined + : matchAvailableSpellcheckLanguage(preferred, input.available)); + if (matched === undefined || seen.has(matched.toLowerCase())) continue; + seen.add(matched.toLowerCase()); + resolved.push(matched); + } + return resolved; +} + +export function preferredSpellcheckLanguages(input: { + readonly systemLocale: string; + readonly preferredSystemLanguages?: readonly string[]; + readonly platformKeyboardLanguages?: readonly string[]; + readonly env: NodeJS.Dict; + readonly configuredLanguages: readonly string[]; + readonly keyboardConfigs?: readonly string[]; +}): string[] { + if (input.configuredLanguages.length > 0) { + return uniquePreferredLanguages(input.configuredLanguages); + } + + const preferred: string[] = []; + const layouts = [ + ...keyboardLayoutsFromEnvironment(input.env), + ...(input.keyboardConfigs ?? []).flatMap(keyboardLayoutsFromConfig), + ]; + for (const layout of layouts) { + const mapped = spellcheckLanguageForKeyboardLayout(layout); + if (mapped !== undefined) preferred.push(mapped); + } + for (const language of input.platformKeyboardLanguages ?? []) { + const normalized = normalizeLocaleTag(language); + if (normalized !== undefined) preferred.push(normalized); + } + for (const language of input.preferredSystemLanguages ?? []) { + const normalized = normalizeLocaleTag(language); + if (normalized !== undefined) preferred.push(normalized); + } + const systemLocale = normalizeLocaleTag(input.systemLocale); + if (systemLocale !== undefined) preferred.push(systemLocale); + preferred.push(...localeTagsFromEnvironment(input.env)); + return uniquePreferredLanguages(preferred); +} + +function uniquePreferredLanguages(languages: readonly string[]): string[] { + const unique: string[] = []; + const seen = new Set(); + for (const language of languages) { + const key = language.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(language); + } + return unique; +} + +export function spellcheckSettingsEqual( + left: SpellcheckSettings, + right: SpellcheckSettings, +): boolean { + return ( + left.spellcheckEnabled === right.spellcheckEnabled && + left.spellcheckLanguages.length === right.spellcheckLanguages.length && + left.spellcheckLanguages.every( + (language, index) => language === right.spellcheckLanguages[index], + ) + ); +} + +export function applySpellCheckerSession( + session: SpellCheckerSession, + input: { + readonly enabled: boolean; + readonly platform: NodeJS.Platform; + readonly configuredLanguages: readonly string[]; + readonly systemLocale: string; + readonly preferredSystemLanguages?: readonly string[]; + readonly platformKeyboardLanguages?: readonly string[]; + readonly env: NodeJS.Dict; + readonly keyboardConfigs?: readonly string[]; + }, +): { readonly enabled: boolean; readonly languages: readonly string[] } { + if (input.platform === "darwin") { + session.setSpellCheckerEnabled(input.enabled); + return { + enabled: input.enabled, + languages: input.enabled ? session.getSpellCheckerLanguages() : [], + }; + } + if (!input.enabled) { + session.setSpellCheckerEnabled(false); + return { enabled: false, languages: [] }; + } + + const languages = resolveSpellCheckerLanguages({ + available: session.availableSpellCheckerLanguages, + preferred: preferredSpellcheckLanguages(input), + requireExact: input.configuredLanguages.length > 0, + }); + if (languages.length === 0) { + // Electron silently falls back to en-US when its language list is empty. + // Disable the checker instead of underlining valid text in another language. + session.setSpellCheckerEnabled(false); + return { enabled: false, languages }; + } + // Keep Electron's persisted fallback dictionary inactive until the complete + // replacement succeeds. A failed update must not leave en-US checking active. + session.setSpellCheckerEnabled(false); + session.setSpellCheckerLanguages(languages); + session.setSpellCheckerEnabled(true); + return { enabled: true, languages }; +} + +const syncBrowserWindowSpellCheckerOnce = ( + window: Electron.BrowserWindow, + settingsOverride?: SpellcheckSettings, +) => + Effect.gen(function* () { + if (window.isDestroyed()) return; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const fileSystem = yield* FileSystem.FileSystem; + + const settings = yield* Effect.gen(function* () { + if (settingsOverride !== undefined) return settingsOverride; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + return Option.getOrElse(yield* clientSettings.get, () => DEFAULT_CLIENT_SETTINGS); + }); + + let systemLocale = ""; + let preferredSystemLanguages: readonly string[] = []; + let platformKeyboardLanguages: readonly string[] = []; + let keyboardConfigs: readonly string[] = []; + const needsAutomaticLanguages = + settings.spellcheckEnabled && + settings.spellcheckLanguages.length === 0 && + environment.platform !== "darwin"; + if (needsAutomaticLanguages) { + systemLocale = yield* electronApp.systemLocale; + preferredSystemLanguages = yield* electronApp.preferredSystemLanguages; + if (environment.platform === "linux") { + keyboardConfigs = yield* Effect.forEach(LINUX_KEYBOARD_CONFIG_PATHS, (path) => + fileSystem.readFileString(path).pipe(Effect.orElseSucceed(() => "")), + ); + } else if (environment.platform === "win32") { + platformKeyboardLanguages = yield* readWindowsKeyboardLanguageTags(); + } + } + + if (window.isDestroyed()) return; + yield* Effect.try({ + try: () => + applySpellCheckerSession(window.webContents.session, { + enabled: settings.spellcheckEnabled, + platform: environment.platform, + configuredLanguages: settings.spellcheckLanguages, + systemLocale, + preferredSystemLanguages, + platformKeyboardLanguages, + env: process.env, + keyboardConfigs, + }), + catch: (cause) => + new ElectronSpellcheckApplyError({ + enabled: settings.spellcheckEnabled, + platform: environment.platform, + cause, + }), + }).pipe( + Effect.catch((error) => + logWarning("failed to apply composer spell checker languages", { + enabled: error.enabled, + platform: error.platform, + cause: error.cause, + }), + ), + ); + }); + +export const syncBrowserWindowSpellChecker = ( + window: Electron.BrowserWindow, + settingsOverride?: SpellcheckSettings, +) => + spellcheckerSyncSemaphore.withPermit(syncBrowserWindowSpellCheckerOnce(window, settingsOverride)); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 37fd873a1b03..c4e9b07dd76c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -34,6 +34,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getSpellcheckInfo, getSystemLocale, getWindowFullscreenState, openExternal, @@ -52,6 +53,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getSpellcheckInfo); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 180e02810801..5cb9e5208ba2 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -16,6 +16,7 @@ export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_SPELLCHECK_INFO_CHANNEL = "desktop:get-spellcheck-info"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.test.ts b/apps/desktop/src/ipc/methods/clientSettings.test.ts new file mode 100644 index 000000000000..352fd8108b78 --- /dev/null +++ b/apps/desktop/src/ipc/methods/clientSettings.test.ts @@ -0,0 +1,94 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; +import { setClientSettings } from "./clientSettings.ts"; + +describe("setClientSettings", () => { + it.effect("serializes persistence and spellcheck synchronization", () => + Effect.gen(function* () { + const firstSetStarted = yield* Deferred.make(); + const releaseFirstSet = yield* Deferred.make(); + const setCalls = yield* Ref.make([]); + let invocation = 0; + + const clientSettingsLayer = Layer.succeed(DesktopClientSettings.DesktopClientSettings, { + get: Effect.succeed(Option.none()), + set: (settings) => + Effect.gen(function* () { + const index = invocation++; + yield* Ref.update(setCalls, (calls) => [ + ...calls, + settings.spellcheckLanguages.join(","), + ]); + if (index === 0) { + yield* Deferred.succeed(firstSetStarted, undefined); + yield* Deferred.await(releaseFirstSet); + } + }), + }); + const window = { + isDestroyed: () => false, + webContents: { + session: { + availableSpellCheckerLanguages: ["en-US", "pt-BR"], + getSpellCheckerLanguages: () => ["en-US"], + setSpellCheckerEnabled: () => undefined, + setSpellCheckerLanguages: () => undefined, + }, + }, + } as unknown as Electron.BrowserWindow; + const layer = Layer.mergeAll( + NodeServices.layer, + clientSettingsLayer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: "darwin", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.mock(ElectronApp.ElectronApp)({}), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ); + const firstSettings = { + ...DEFAULT_CLIENT_SETTINGS, + spellcheckLanguages: ["en-US"], + }; + const secondSettings = { + ...DEFAULT_CLIENT_SETTINGS, + spellcheckLanguages: ["pt-BR"], + }; + + yield* Effect.gen(function* () { + const firstFiber = yield* setClientSettings + .handler(firstSettings) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstSetStarted); + const secondFiber = yield* setClientSettings + .handler(secondSettings) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.deepEqual(yield* Ref.get(setCalls), ["en-US"]); + + yield* Deferred.succeed(releaseFirstSet, undefined); + yield* Fiber.join(firstFiber); + yield* Fiber.join(secondFiber); + assert.deepEqual(yield* Ref.get(setCalls), ["en-US", "pt-BR"]); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index dd0625759e94..d8a6815ae8d8 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -2,11 +2,16 @@ import { ClientSettingsSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ElectronSpellcheck from "../../electron/ElectronSpellcheck.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; +const setClientSettingsSemaphore = Semaphore.makeUnsafe(1); + export const getClientSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, payload: Schema.Void, @@ -22,7 +27,23 @@ export const setClientSettings = DesktopIpc.makeIpcMethod({ payload: ClientSettingsSchema, result: Schema.Void, handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { - const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; - yield* clientSettings.set(settings); + yield* setClientSettingsSemaphore.withPermit( + Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const previousSettings = yield* clientSettings.get; + yield* clientSettings.set(settings); + if ( + Option.isSome(previousSettings) && + ElectronSpellcheck.spellcheckSettingsEqual(previousSettings.value, settings) + ) { + return; + } + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(window)) { + yield* ElectronSpellcheck.syncBrowserWindowSpellChecker(window.value, settings); + } + }), + ); }), }); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d39563..f77f9f3d0d0c 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -5,10 +5,15 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import { + getLocalEnvironmentBootstraps, + getSpellcheckInfo, + getWindowFullscreenState, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +151,63 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("getSpellcheckInfo", () => { + it.effect("publishes every dictionary supported by the current Electron session", () => { + const window = { + webContents: { + session: { + availableSpellCheckerLanguages: ["en-US", "pt-BR", "fr"], + }, + }, + } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.deepEqual(yield* getSpellcheckInfo.handler(), { + canSelectLanguages: true, + availableLanguages: ["en-US", "pt-BR", "fr"], + }); + }).pipe( + Effect.provide( + Layer.merge( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: "linux", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ), + ); + }); + + it.effect("hides language selection for the native macOS checker", () => { + const window = { + webContents: { session: { availableSpellCheckerLanguages: ["en-US"] } }, + } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.deepEqual(yield* getSpellcheckInfo.handler(), { + canSelectLanguages: false, + availableLanguages: [], + }); + }).pipe( + Effect.provide( + Layer.merge( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: "darwin", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0c7e90b95072..bc2d61509e0e 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -2,6 +2,7 @@ import { ContextMenuItemSchema, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, + DesktopSpellcheckInfoSchema, DesktopThemeSchema, EDITORS, EditorId, @@ -74,6 +75,24 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getSpellcheckInfo = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_SPELLCHECK_INFO_CHANNEL, + result: Schema.NullOr(DesktopSpellcheckInfoSchema), + handler: Effect.fn("desktop.ipc.window.getSpellcheckInfo")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(window)) return null; + const canSelectLanguages = environment.platform !== "darwin"; + return { + canSelectLanguages, + availableLanguages: canSelectLanguages + ? [...window.value.webContents.session.availableSpellCheckerLanguages] + : [], + }; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ee03141f2d82..ea13b5db7b67 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -39,6 +39,21 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, + getSpellcheckInfo: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_SPELLCHECK_INFO_CHANNEL); + if ( + typeof result !== "object" || + result === null || + !("canSelectLanguages" in result) || + typeof result.canSelectLanguages !== "boolean" || + !("availableLanguages" in result) || + !Array.isArray(result.availableLanguages) || + !result.availableLanguages.every((language: unknown) => typeof language === "string") + ) { + return null; + } + return result as ReturnType>; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c17d58215ea..f23f14b2e2f6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -47,6 +47,8 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, timestampFormat: "24-hour", + spellcheckEnabled: true, + spellcheckLanguages: [], wordWrap: true, }; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 3d912a5d5aa8..cb41711748b6 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -26,6 +26,7 @@ function makeElectronAppLayer( return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + preferredSystemLanguages: Effect.succeed(["en-US"]), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d3..76587f652d83 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -31,6 +31,7 @@ const environmentInput = { const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + preferredSystemLanguages: Effect.succeed(["en-US"]), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..aa8931443e83 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -64,6 +65,8 @@ function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); let zoomLevel = 0; + const setSpellCheckerLanguages = vi.fn(); + const setSpellCheckerEnabled = vi.fn(); const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), @@ -80,6 +83,12 @@ function makeFakeBrowserWindow() { reload: vi.fn(), replaceMisspelling: vi.fn(), send: vi.fn(), + session: { + availableSpellCheckerLanguages: ["en-US", "en-GB", "pt-BR", "pt-PT", "es-ES", "fr"], + getSpellCheckerLanguages: vi.fn(() => ["en-US"]), + setSpellCheckerLanguages, + setSpellCheckerEnabled, + }, setBackgroundThrottling: vi.fn(), setWindowOpenHandler: vi.fn(), }; @@ -127,6 +136,8 @@ function makeFakeBrowserWindow() { setZoomLevel: webContents.setZoomLevel, setBackgroundThrottling: webContents.setBackgroundThrottling, setAutoHideCursor: window.setAutoHideCursor, + setSpellCheckerLanguages, + setSpellCheckerEnabled, webContentsListeners, windowListeners, }; @@ -138,6 +149,8 @@ const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClien const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ quit: Effect.void, + preferredSystemLanguages: Effect.succeed(["en-US"]), + systemLocale: Effect.succeed("en-US"), }); const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { @@ -203,10 +216,17 @@ function makeTestLayer(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly clientSettingsGet?: DesktopClientSettings.DesktopClientSettings["Service"]["get"]; readonly openedExternalUrls?: unknown[]; readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const clientSettingsLayer = + input.clientSettingsGet === undefined + ? desktopClientSettingsLayer + : Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: input.clientSettingsGet, + }); const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), @@ -262,10 +282,11 @@ function makeTestLayer(input: { return DesktopWindow.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, - desktopClientSettingsLayer, + clientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, electronAppLayer, @@ -367,6 +388,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n const layer = DesktopWindow.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), @@ -461,6 +483,44 @@ describe("DesktopWindow", () => { assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + assert.deepEqual(fakeWindow.setSpellCheckerEnabled.mock.calls, [[true]]); + assert.equal(fakeWindow.setSpellCheckerLanguages.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("starts loading without waiting for spellchecker synchronization", () => + Effect.gen(function* () { + const events: string[] = []; + const settingsRead = yield* Deferred.make(); + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.loadURL.mockImplementation(() => { + events.push("load"); + return Promise.resolve(); + }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + clientSettingsGet: Effect.gen(function* () { + yield* Effect.yieldNow; + events.push("spellcheck"); + yield* Deferred.succeed(settingsRead, undefined); + return Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + spellcheckLanguages: ["en-US"], + }); + }), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* Deferred.await(settingsRead); + yield* Effect.yieldNow; + assert.deepEqual(events, ["load", "spellcheck"]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..bc9473e7917f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -2,9 +2,11 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as Electron from "electron"; @@ -13,9 +15,11 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; +import { syncBrowserWindowSpellChecker } from "../electron/ElectronSpellcheck.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { @@ -26,7 +30,6 @@ import { import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; -import * as ElectronApp from "../electron/ElectronApp.ts"; import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; @@ -66,6 +69,8 @@ type DesktopWindowRuntimeServices = | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme | ElectronWindow.ElectronWindow + | FileSystem.FileSystem + | ChildProcessSpawner.ChildProcessSpawner | PreviewManager.PreviewManager; export type DesktopWindowError = @@ -375,6 +380,7 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + runFork(syncBrowserWindowSpellChecker(window)); let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..9f7556d0a50e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -68,6 +68,7 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "~/lib/terminalContext"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { @@ -1541,6 +1542,7 @@ function ComposerPromptEditorInner({ editorRef, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); + const spellcheckEnabled = useClientSettings((settings) => settings.spellcheckEnabled); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); @@ -1758,6 +1760,7 @@ function ComposerPromptEditorInner({ className, )} data-testid="composer-editor" + spellCheck={spellcheckEnabled} aria-placeholder={placeholder} placeholder={} onPaste={onPaste} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9539f95914cb..5bbdff77420e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -114,6 +114,7 @@ import { NumberFieldInput, } from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Checkbox } from "../ui/checkbox"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -498,6 +499,10 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Auto-settle merged threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.spellcheckEnabled !== DEFAULT_UNIFIED_SETTINGS.spellcheckEnabled || + settings.spellcheckLanguages.length > 0 + ? ["Spelling"] + : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] @@ -569,6 +574,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + settings.spellcheckEnabled, + settings.spellcheckLanguages, followSystem, theme, themeHalves, @@ -640,6 +647,8 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + spellcheckEnabled: DEFAULT_UNIFIED_SETTINGS.spellcheckEnabled, + spellcheckLanguages: DEFAULT_UNIFIED_SETTINGS.spellcheckLanguages, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -1319,6 +1328,134 @@ function WordWrapRow() { ); } +const SPELLCHECK_LANGUAGE_DISPLAY_NAMES = new Intl.DisplayNames(["en"], { type: "language" }); + +function spellcheckLanguageLabel(language: string) { + try { + return SPELLCHECK_LANGUAGE_DISPLAY_NAMES.of(language) ?? language; + } catch { + return language; + } +} + +function useSpellcheckLanguageModel(configuredLanguages: readonly string[]) { + const spellcheckInfo = useMemo( + () => + isElectron && typeof window !== "undefined" + ? (window.desktopBridge?.getSpellcheckInfo?.() ?? null) + : null, + [], + ); + + return useMemo(() => { + const availableLanguages = spellcheckInfo?.availableLanguages ?? []; + return { + spellcheckInfo, + canSelectLanguages: spellcheckInfo?.canSelectLanguages === true, + availableLanguageSet: new Set(availableLanguages), + selectedLanguages: new Set(configuredLanguages), + languageOptions: Array.from(new Set([...availableLanguages, ...configuredLanguages])) + .map((id) => ({ id, label: spellcheckLanguageLabel(id) })) + .sort((left, right) => left.label.localeCompare(right.label)), + }; + }, [configuredLanguages, spellcheckInfo]); +} + +function SpellcheckRow() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const { + spellcheckInfo, + canSelectLanguages, + availableLanguageSet, + selectedLanguages, + languageOptions, + } = useSpellcheckLanguageModel(settings.spellcheckLanguages); + const description = !isElectron + ? "Underline misspelled words in the composer. The browser picks the dictionary." + : spellcheckInfo?.canSelectLanguages === false + ? "Underline misspelled words in the composer. macOS detects the language automatically." + : "Underline misspelled words in the composer. Leave dictionaries unchecked to use OS languages and platform keyboard settings."; + + return ( + 0 ? ( + + updateSettings({ + spellcheckEnabled: DEFAULT_UNIFIED_SETTINGS.spellcheckEnabled, + spellcheckLanguages: DEFAULT_UNIFIED_SETTINGS.spellcheckLanguages, + }) + } + /> + ) : null + } + control={ + updateSettings({ spellcheckEnabled: Boolean(checked) })} + aria-label="Check spelling in the composer" + /> + } + > + {canSelectLanguages && settings.spellcheckEnabled ? ( +
+

+ {selectedLanguages.size === 0 + ? "Automatic language selection is on." + : `${selectedLanguages.size} ${selectedLanguages.size === 1 ? "dictionary" : "dictionaries"} selected.`} +

+ {languageOptions.length > 0 ? ( +
+ {languageOptions.map((option) => { + const isAvailable = availableLanguageSet.has(option.id); + return ( + + ); + })} +
+ ) : ( +

+ No configurable dictionaries are available in this Electron build. +

+ )} +
+ ) : null} +
+ ); +} + function FontSettingsGroup() { return ( <> @@ -1416,6 +1553,7 @@ function TypographySection() { > {advanced ? : } + ); } diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 09fd7a9a6a0b..5691675d8a63 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -84,6 +84,10 @@ describe("searchSettings", () => { id: "word-wrap", to: "/settings/appearance", }); + expect(searchSettings("check spelling")[0]).toMatchObject({ + id: "check-spelling", + to: "/settings/appearance", + }); expect(searchSettings("environment identification")[0]).toMatchObject({ id: "environment-identification", to: "/settings/appearance", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e49f77a834eb..d3d5508b8f10 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -100,6 +100,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Word wrap", to: "/settings/appearance", }, + { + id: "check-spelling", + title: "Check spelling", + to: "/settings/appearance", + }, { id: "project-grouping", title: "Project grouping", diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b0..ef08d0e9d9ec 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,14 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +The desktop app underlines misspelled words using the operating system's preferred languages. +Automatic selection prioritizes the active Windows input method, includes other installed Windows +input methods, and, on Linux, reads XKB environment settings, `/etc/vconsole.conf`, and +`/etc/default/keyboard`. This means an English locale with a Brazilian keyboard can check both +English and Portuguese. If no supported dictionary matches, T3 Code disables checking instead of +silently falling back to English. Turn checking off or select any dictionary supported by the +current desktop build in Settings → Appearance → Check spelling. macOS uses its native automatic +language detection. + +![Spellcheck settings with automatic and per-dictionary controls](./spellcheck-settings.png) diff --git a/docs/user/spellcheck-settings.png b/docs/user/spellcheck-settings.png new file mode 100644 index 000000000000..cd23ec9d6156 Binary files /dev/null and b/docs/user/spellcheck-settings.png differ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93f074f7be56..93fdec04d739 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -157,6 +157,16 @@ export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export interface DesktopSpellcheckInfo { + readonly canSelectLanguages: boolean; + readonly availableLanguages: readonly string[]; +} + +export const DesktopSpellcheckInfoSchema: Schema.Codec = Schema.Struct({ + canSelectLanguages: Schema.Boolean, + availableLanguages: Schema.Array(Schema.String), +}); + export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", "idle", @@ -1070,6 +1080,11 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; + /** + * Languages accepted by this Electron build's spellchecker. The native + * macOS checker selects its own languages, so canSelectLanguages is false. + */ + getSpellcheckInfo?: () => DesktopSpellcheckInfo | null; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..9bbcb00617f8 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -18,6 +18,32 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ClientSettings spellcheck", () => { + it("defaults to enabled with automatic languages", () => { + const settings = decodeClientSettings({}); + expect(settings.spellcheckEnabled).toBe(true); + expect(settings.spellcheckLanguages).toEqual([]); + }); + + it("accepts an explicit Hunspell language list", () => { + expect( + decodeClientSettings({ spellcheckEnabled: false, spellcheckLanguages: ["pt-BR", "en-US"] }), + ).toMatchObject({ + spellcheckEnabled: false, + spellcheckLanguages: ["pt-BR", "en-US"], + }); + expect( + decodeClientSettingsPatch({ spellcheckLanguages: ["pt-BR"] }).spellcheckLanguages, + ).toEqual(["pt-BR"]); + expect(decodeClientSettingsPatch({ spellcheckEnabled: false }).spellcheckEnabled).toBe(false); + }); + + it("rejects an empty language tag", () => { + expect(() => decodeClientSettings({ spellcheckLanguages: [""] })).toThrow(); + expect(() => decodeClientSettingsPatch({ spellcheckLanguages: [" "] })).toThrow(); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..bb8ff26a61c0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -242,6 +242,12 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + // Composer spelling. Empty `spellcheckLanguages` means automatic: OS locale + // plus keyboard layout on desktop. An explicit list is Hunspell tags only. + spellcheckEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + spellcheckLanguages: Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(35))).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -912,6 +918,10 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + spellcheckEnabled: Schema.optionalKey(Schema.Boolean), + spellcheckLanguages: Schema.optionalKey( + Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(35))), + ), wordWrap: Schema.optionalKey(Schema.Boolean), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;