diff --git a/packages/app/src/runtime/i18n/desktop-native.ts b/packages/app/src/runtime/i18n/desktop-native.ts index 04135001023..7cc60c8e0f6 100644 --- a/packages/app/src/runtime/i18n/desktop-native.ts +++ b/packages/app/src/runtime/i18n/desktop-native.ts @@ -1,3 +1,5 @@ +import { flow, Option, Schema } from "effect" + export const DESKTOP_NATIVE_LOCALES = [ "en", "zh", @@ -302,6 +304,9 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.picker.error.notSelected": "File was not selected by the picker", "desktop.picker.error.sizeLimit": "Selected attachments exceed the {{limit}} MB limit", + + "desktop.contextMenu.spellcheck": "Spellcheck", + "desktop.contextMenu.spellcheckLanguages": "Spellcheck Languages", } as const export type DesktopNativeKey = keyof typeof DESKTOP_NATIVE_ENGLISH @@ -310,6 +315,7 @@ export type DesktopNativeMessages = Record export type DesktopNativeBundle = { locale: DesktopNativeLocale; messages: DesktopNativeMessages } +// SAFETY: DESKTOP_NATIVE_ENGLISH is a const literal, so its own keys are exactly DesktopNativeKey. export const DESKTOP_NATIVE_KEYS = Object.keys(DESKTOP_NATIVE_ENGLISH) as DesktopNativeKey[] export const DESKTOP_NATIVE_MAX_PAYLOAD_BYTES = 64 * 1024 @@ -320,35 +326,24 @@ export function createDesktopNativeBundle( ): DesktopNativeBundle { return { locale, + // SAFETY: the entries map every DesktopNativeKey to a string, which is exactly DesktopNativeMessages. messages: Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key) => [key, translate(key)])) as DesktopNativeMessages, } } -export function parseDesktopNativeBundle(value: unknown): DesktopNativeBundle | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined +const DesktopNativeBundleSchema = Schema.Struct({ + locale: Schema.Literals(DESKTOP_NATIVE_LOCALES), + messages: Schema.Record(Schema.Literals(DESKTOP_NATIVE_KEYS), Schema.String), +}).check( + Schema.makeFilter( + (bundle) => new TextEncoder().encode(JSON.stringify(bundle)).byteLength <= DESKTOP_NATIVE_MAX_PAYLOAD_BYTES, + ), +) - try { - if (new TextEncoder().encode(JSON.stringify(value)).byteLength > DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) return undefined - } catch { - return undefined - } +const decodeDesktopNativeBundle = Schema.decodeUnknownOption(DesktopNativeBundleSchema, { onExcessProperty: "error" }) - const bundle = value as { locale?: unknown; messages?: unknown } - - if (!DESKTOP_NATIVE_LOCALES.some((locale) => locale === bundle.locale)) return undefined - - if (!bundle.messages || typeof bundle.messages !== "object" || Array.isArray(bundle.messages)) return undefined - const messages = bundle.messages as Record - const keys = Object.keys(messages) - - if (keys.length !== DESKTOP_NATIVE_KEYS.length) return undefined - - if (!DESKTOP_NATIVE_KEYS.every((key) => typeof messages[key] === "string")) return undefined - - if (!keys.every((key) => key in DESKTOP_NATIVE_ENGLISH)) return undefined - - return bundle as DesktopNativeBundle -} +/** Validates a bundle the renderer sent over IPC: a supported locale, exactly the native keys, and a bounded size. */ +export const parseDesktopNativeBundle = flow(decodeDesktopNativeBundle, Option.getOrUndefined) export function formatDesktopNativeMessage(message: string, params?: Record) { if (!params) return message diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 82c47307bc6..f0298151794 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -1005,6 +1005,8 @@ export const dict = { "settings.general.row.followUpBehavior.steer": "Steer", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", + "settings.general.row.spellcheck.title": "Spellcheck", + "settings.general.row.spellcheck.description": "Underline misspelled words while typing", "sound.option.none": "None", "sound.option.alert01": "Alert 01", diff --git a/packages/app/src/runtime/i18n/hr.ts b/packages/app/src/runtime/i18n/hr.ts index 08b53b437b6..f47a46269e7 100644 --- a/packages/app/src/runtime/i18n/hr.ts +++ b/packages/app/src/runtime/i18n/hr.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Predloženo", "command.category.view": "Prikaz", "command.category.project": "Projekt", diff --git a/packages/app/src/runtime/i18n/hu.ts b/packages/app/src/runtime/i18n/hu.ts index dfbf1b40476..989c665c6db 100644 --- a/packages/app/src/runtime/i18n/hu.ts +++ b/packages/app/src/runtime/i18n/hu.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Javasolt", "command.category.view": "Nézet", "command.category.project": "Projekt", diff --git a/packages/app/src/runtime/i18n/is.ts b/packages/app/src/runtime/i18n/is.ts index d59bed90c73..a38af244fd6 100644 --- a/packages/app/src/runtime/i18n/is.ts +++ b/packages/app/src/runtime/i18n/is.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Tillögur", "command.category.view": "Skoða", "command.category.project": "Verkefni", diff --git a/packages/app/src/runtime/i18n/lt.ts b/packages/app/src/runtime/i18n/lt.ts index 34758c917e1..c4533c4e294 100644 --- a/packages/app/src/runtime/i18n/lt.ts +++ b/packages/app/src/runtime/i18n/lt.ts @@ -72,7 +72,7 @@ const desktop = [ ] export const dict = { - ...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])), + ...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])), "command.category.suggested": "Siūloma", "command.category.view": "Rodinys", "command.category.project": "Projektas", diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index ecbf0b2eb42..38a12334f22 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -108,6 +108,12 @@ type PlatformBase = { /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Get whether the native spellchecker underlines misspelled words (desktop only) */ + getSpellcheckEnabled?(): Promise + + /** Turn the native spellchecker on or off (desktop only) */ + setSpellcheckEnabled?(enabled: boolean): Promise + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 7d35c909836..b60849019d3 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -249,6 +249,17 @@ export const SettingsGeneral: Component = () => { void update.catch(() => setPinchZoom(!checked)) } + const [spellcheck, { mutate: setSpellcheck }] = createResource( + () => desktop() && "getSpellcheckEnabled" in platform, + () => platform.getSpellcheckEnabled?.().catch(() => true) ?? true, + { initialValue: true }, + ) + + const onSpellcheckChange = (checked: boolean) => { + setSpellcheck(checked) + void platform.setSpellcheckEnabled?.(checked).catch(() => setSpellcheck(!checked)) + } + const GeneralSection = () => (

{language.t("settings.general.section.general")}

@@ -282,6 +293,14 @@ export const SettingsGeneral: Component = () => {
+ +
+ +
+
diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index 3089cbdc045..9f5d783d569 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -55,6 +55,13 @@ export const clientSettings: Entry[] = [ target: "settings-pinch-zoom", available: "desktop", }, + { + tab: "general", + label: "settings.general.row.spellcheck.title", + target: "settings-spellcheck", + keywords: "spelling spell check dictionary", + available: "desktop", + }, { tab: "general", label: "settings.general.row.mobileTitlebarBottom.title", diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index 90e03c4de91..c2542481484 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -8,6 +8,7 @@ import { setForceFocus } from "../native/debug" import { showCliInstaller } from "../native/install-cli" import { DesktopLogging, scoped } from "../native/logging" import { createMenu, sendMenuCommand } from "../native/menu" +import { getSpellcheckEnabled, setSpellcheckEnabled } from "../native/spellcheck" import { setNativeTranslations } from "../native/translations" import { IpcPortHandoff } from "../ipc-transport" import { ApplicationLifecycle } from "../lifecycle" @@ -37,6 +38,8 @@ export const appHandlers = AppRpcs.toLayer( AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie), AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie), AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)), + AppGetSpellcheckEnabled: () => Effect.sync(getSpellcheckEnabled), + AppSetSpellcheckEnabled: ({ enabled }) => Effect.sync(() => setSpellcheckEnabled(enabled)), AppExportDebugLogs: () => logging.exportDebug, AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)), AppRecordFatalRendererError: ({ error }) => diff --git a/packages/desktop/src/main/lifecycle/environment.ts b/packages/desktop/src/main/lifecycle/environment.ts index 4b9b4e1fd41..6988e7095c6 100644 --- a/packages/desktop/src/main/lifecycle/environment.ts +++ b/packages/desktop/src/main/lifecycle/environment.ts @@ -6,12 +6,18 @@ import { DesktopPaths } from "../paths" import { getUserShell, loadShellEnv } from "../service/shell-env" import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows" import { scoped } from "../native/logging" +import { applySpellcheck, spellcheckMenuItems } from "../native/spellcheck" // electron-context-menu attaches to every existing and future window, so it can load once the first // window is up instead of holding up startup with its dependency tree. export const installContextMenu = Effect.gen(function* () { const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu")) - contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + contextMenu({ + showSaveImageAs: true, + showLookUpSelection: false, + showSearchWithGoogle: false, + append: (_actions, params) => (params.isEditable ? spellcheckMenuItems() : []), + }) }) export const prepareApplicationEnvironment = Effect.gen(function* () { @@ -45,12 +51,13 @@ export const prepareDesktop = Effect.gen(function* () { ) registerRendererProtocol(paths.rendererRoot) setDockIcon(path, paths) + applySpellcheck() }) export const loadProxyEnvironment = Effect.gen(function* () { yield* Effect.try(() => { ensureLoopbackNoProxy() - // Electron 41.2 has a newer Node API than the current @types/node package. + // SAFETY: Electron 41.2 ships a Node with http.setGlobalProxyFromEnv, which the current @types/node lacks. const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void } proxyAwareHttp.setGlobalProxyFromEnv() }).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error }))) diff --git a/packages/desktop/src/main/native/spellcheck-languages.test.ts b/packages/desktop/src/main/native/spellcheck-languages.test.ts new file mode 100644 index 00000000000..c5dee7b51cb --- /dev/null +++ b/packages/desktop/src/main/native/spellcheck-languages.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { defaultSpellcheckLanguages } from "./spellcheck-languages" + +// A slice of Electron 44's Linux and Windows list, including aliases such as de and de-DE. +const available = ["de", "de-DE", "en", "en-AU", "en-GB", "en-US", "es", "es-MX", "fr", "fr-FR", "pt", "pt-BR"] + +test.each([ + [["de-DE"], ["de-DE", "en-US"]], + [ + ["de-DE", "de", "en-US", "en"], + ["de-DE", "en-US"], + ], + [ + ["de-AT", "fr-CA"], + ["de", "fr", "en-US"], + ], + [ + ["en-GB", "de-DE"], + ["en-GB", "de-DE"], + ], + [["en-NZ"], ["en"]], + [ + ["es-MX", "es-ES"], + ["es-MX", "en-US"], + ], + [["pt-AO"], ["pt", "en-US"]], + [["DE-de"], ["de-DE", "en-US"]], + [["ja-JP"], ["en-US"]], + [[], ["en-US"]], +])("system languages %p use dictionaries %p", (preferred, languages) => { + expect(defaultSpellcheckLanguages(available, preferred)).toEqual(languages) +}) diff --git a/packages/desktop/src/main/native/spellcheck-languages.ts b/packages/desktop/src/main/native/spellcheck-languages.ts new file mode 100644 index 00000000000..4539b083e0d --- /dev/null +++ b/packages/desktop/src/main/native/spellcheck-languages.ts @@ -0,0 +1,16 @@ +const base = (tag: string) => tag.split("-")[0].toLowerCase() + +// One dictionary per system language, matched exactly or by base language (de-AT -> de). Chromium +// lists aliases such as de and de-DE separately, so a later tag for the same language is skipped. +// US English is added when none of them is English, because prompts mix prose with English code terms. +export function defaultSpellcheckLanguages(available: readonly string[], preferred: readonly string[]) { + const find = (tag: string) => available.find((language) => language.toLowerCase() === tag.toLowerCase()) + + const languages = preferred + .flatMap((tag) => find(tag) ?? find(base(tag)) ?? []) + .filter((language, index, all) => all.findIndex((other) => base(other) === base(language)) === index) + + if (languages.some((language) => base(language) === "en") || !available.includes("en-US")) return languages + + return [...languages, "en-US"] +} diff --git a/packages/desktop/src/main/native/spellcheck.ts b/packages/desktop/src/main/native/spellcheck.ts new file mode 100644 index 00000000000..a551c12eb5e --- /dev/null +++ b/packages/desktop/src/main/native/spellcheck.ts @@ -0,0 +1,74 @@ +import { app, session, type MenuItemConstructorOptions } from "electron" +import { SPELLCHECK_ENABLED_KEY, SPELLCHECK_LANGUAGES_KEY } from "../storage/keys" +import { getStore } from "../storage/store" +import { defaultSpellcheckLanguages } from "./spellcheck-languages" +import { nativeLanguageOptions, nativeT } from "./translations" + +// macOS checks spelling with the system spellchecker, which detects the language itself; Electron +// ignores dictionary choices there. +const choosesLanguages = process.platform !== "darwin" + +export function applySpellcheck() { + // Setting languages also turns the spellchecker on, so the stored on/off state goes last. + if (choosesLanguages) session.defaultSession.setSpellCheckerLanguages(spellcheckLanguages()) + + session.defaultSession.setSpellCheckerEnabled(getSpellcheckEnabled()) +} + +export function getSpellcheckEnabled() { + return getStore().get(SPELLCHECK_ENABLED_KEY) !== false +} + +export function setSpellcheckEnabled(enabled: boolean) { + getStore().set(SPELLCHECK_ENABLED_KEY, enabled) + session.defaultSession.setSpellCheckerEnabled(enabled) +} + +// Mirrors the Spellcheck menu in chat apps: a checkbox, and a dictionary picker where Electron owns +// the dictionaries. +export function spellcheckMenuItems(): MenuItemConstructorOptions[] { + const enabled = getSpellcheckEnabled() + const current = session.defaultSession.getSpellCheckerLanguages() + + const toggle: MenuItemConstructorOptions = { + label: nativeT("desktop.contextMenu.spellcheck"), + type: "checkbox", + checked: enabled, + click: () => setSpellcheckEnabled(!enabled), + } + + if (!choosesLanguages) return [{ type: "separator" }, toggle] + + return [ + { type: "separator" }, + toggle, + { + label: nativeT("desktop.contextMenu.spellcheckLanguages"), + enabled, + submenu: nativeLanguageOptions(session.defaultSession.availableSpellCheckerLanguages).map((option) => ({ + label: option.label, + type: "checkbox", + checked: current.includes(option.tag), + // The last dictionary stays; the Spellcheck checkbox is how to stop checking entirely. + enabled: !(current.length === 1 && current[0] === option.tag), + click: () => { + const next = current.includes(option.tag) + ? current.filter((language) => language !== option.tag) + : [...current, option.tag] + + getStore().set(SPELLCHECK_LANGUAGES_KEY, next) + session.defaultSession.setSpellCheckerLanguages(next) + }, + })), + }, + ] +} + +// Dictionaries picked from the menu win; otherwise follow the system languages on every launch. +function spellcheckLanguages() { + const available = session.defaultSession.availableSpellCheckerLanguages + const stored = getStore().get(SPELLCHECK_LANGUAGES_KEY) + const chosen = Array.isArray(stored) ? available.filter((language) => stored.includes(language)) : [] + + return chosen.length ? chosen : defaultSpellcheckLanguages(available, app.getPreferredSystemLanguages()) +} diff --git a/packages/desktop/src/main/native/translations.ts b/packages/desktop/src/main/native/translations.ts index fc1a319d5c8..0640d7e4424 100644 --- a/packages/desktop/src/main/native/translations.ts +++ b/packages/desktop/src/main/native/translations.ts @@ -29,6 +29,16 @@ export function nativeT(key: DesktopNativeKey, params?: Record ({ tag, label: names.of(tag) ?? tag })) + .toSorted((a, b) => a.label.localeCompare(b.label, locale)) +} + /** The app locale the renderer last pushed. GUI plugin catalogs resolve against it. */ export function nativeLocale() { return bundle.locale diff --git a/packages/desktop/src/main/storage/keys.ts b/packages/desktop/src/main/storage/keys.ts index f4ddd396490..f8f823f2783 100644 --- a/packages/desktop/src/main/storage/keys.ts +++ b/packages/desktop/src/main/storage/keys.ts @@ -6,6 +6,10 @@ export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const ZOOM_LEVEL_KEY = "zoomLevel" +export const SPELLCHECK_ENABLED_KEY = "spellcheckEnabled" + +export const SPELLCHECK_LANGUAGES_KEY = "spellcheckLanguages" + export const BACKGROUND_COLOR_KEY = "backgroundColor" export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 14accea7b77..d67385c1a44 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -71,6 +71,8 @@ export type ElectronAPI = { setBackgroundColor(color: string): Promise exportDebugLogs(): Promise setForceFocus(enabled: boolean): Promise + getSpellcheckEnabled(): Promise + setSpellcheckEnabled(enabled: boolean): Promise recordFatalRendererError(error: FatalRendererError): Promise setNativeTranslations(bundle: DesktopNativeBundle): Promise } diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index b12e4321956..f1e994456a2 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -94,6 +94,8 @@ export const api: ElectronAPI = { setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }), exportDebugLogs: () => invoke("AppExportDebugLogs"), setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }), + getSpellcheckEnabled: () => invoke("AppGetSpellcheckEnabled"), + setSpellcheckEnabled: (enabled) => invoke("AppSetSpellcheckEnabled", { enabled }), recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }), setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }), } diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 64c000c0519..7b6ede01589 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -43,6 +43,8 @@ export function createDesktopPlatform(api: ElectronAPI, windowState: DesktopWind setTrafficLightsPosition: (position) => api.setTrafficLightsPosition(position), getPinchZoomEnabled: () => api.getPinchZoomEnabled(), setPinchZoomEnabled, + getSpellcheckEnabled: () => api.getSpellcheckEnabled(), + setSpellcheckEnabled: (enabled) => api.setSpellcheckEnabled(enabled), onDragCancel: (callback) => { window.addEventListener(DragCancelEvent, callback) diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index 3e6de686cb1..9f3802806c3 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -54,6 +54,12 @@ export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError }, }) +export const AppGetSpellcheckEnabled = Rpc.make("AppGetSpellcheckEnabled", { success: Schema.Boolean }) + +export const AppSetSpellcheckEnabled = Rpc.make("AppSetSpellcheckEnabled", { + payload: { enabled: Schema.Boolean }, +}) + export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", { payload: { value: Schema.Unknown }, }) @@ -71,6 +77,8 @@ export const AppRpcs = RpcGroup.make( AppSetBackgroundColor, AppExportDebugLogs, AppSetForceFocus, + AppGetSpellcheckEnabled, + AppSetSpellcheckEnabled, AppRecordFatalRendererError, AppSetNativeTranslations, AppRelaunch,