Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 18 additions & 23 deletions packages/app/src/runtime/i18n/desktop-native.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { flow, Option, Schema } from "effect"

export const DESKTOP_NATIVE_LOCALES = [
"en",
"zh",
Expand Down Expand Up @@ -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
Expand All @@ -310,6 +315,7 @@ export type DesktopNativeMessages = Record<DesktopNativeKey, string>

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
Expand All @@ -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

try {
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) return undefined
} catch {
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,
),
)

const bundle = value as { locale?: unknown; messages?: unknown }
const decodeDesktopNativeBundle = Schema.decodeUnknownOption(DesktopNativeBundleSchema, { onExcessProperty: "error" })

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<string, unknown>
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<string, string | number>) {
if (!params) return message
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/i18n/hr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/i18n/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/i18n/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/i18n/lt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/runtime/platform/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void

/** Get whether the native spellchecker underlines misspelled words (desktop only) */
getSpellcheckEnabled?(): Promise<boolean>

/** Turn the native spellchecker on or off (desktop only) */
setSpellcheckEnabled?(enabled: boolean): Promise<void>

/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void

Expand Down
19 changes: 19 additions & 0 deletions packages/app/src/settings/general/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.general")}</h3>
Expand Down Expand Up @@ -282,6 +293,14 @@ export const SettingsGeneral: Component = () => {
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.spellcheck.title")}
description={language.t("settings.general.row.spellcheck.description")}
>
<div data-action="settings-spellcheck">
<Switch checked={spellcheck.latest} onChange={onSpellcheckChange} />
</div>
</SettingsRow>
</Show>

<PluginSettingsSections page="general" section="general" />
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/settings/search-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
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",
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/main/ipc-handlers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }) =>
Expand Down
11 changes: 9 additions & 2 deletions packages/desktop/src/main/lifecycle/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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 })))
Expand Down
32 changes: 32 additions & 0 deletions packages/desktop/src/main/native/spellcheck-languages.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
16 changes: 16 additions & 0 deletions packages/desktop/src/main/native/spellcheck-languages.ts
Original file line number Diff line number Diff line change
@@ -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"]
}
74 changes: 74 additions & 0 deletions packages/desktop/src/main/native/spellcheck.ts
Original file line number Diff line number Diff line change
@@ -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())
}
10 changes: 10 additions & 0 deletions packages/desktop/src/main/native/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export function nativeT(key: DesktopNativeKey, params?: Record<string, string |
return formatDesktopNativeMessage(bundle.messages[key], params)
}

/** Language tags with their names in the app locale, sorted for a menu. */
export function nativeLanguageOptions(tags: readonly string[]) {
const locale = DESKTOP_NATIVE_LOCALE_TAGS[bundle.locale]
const names = new Intl.DisplayNames([locale], { type: "language" })

return tags
.map((tag) => ({ 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
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/main/storage/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export type ElectronAPI = {
setBackgroundColor(color: string): Promise<void>
exportDebugLogs(): Promise<string>
setForceFocus(enabled: boolean): Promise<void>
getSpellcheckEnabled(): Promise<boolean>
setSpellcheckEnabled(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
}
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}
Loading
Loading