/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {app, ipcMain, type Session, shell, type WebContents} from 'electron'; import log from 'electron-log'; type LanguageCode = string; interface SpellcheckState { enabled: boolean; languages: Array; } interface RendererSpellcheckState { enabled?: boolean; languages?: Array; } const defaultState: SpellcheckState = { enabled: true, languages: [], }; const isMac = process.platform === 'darwin'; const isWindows = process.platform === 'win32'; const showSpellMenu = isMac || isWindows; const normalizeLanguage = (code: string): string => code.toLowerCase(); const contextSourceByWebContents = new WeakMap(); let contextIpcRegistered = false; const ensureContextIpc = () => { if (contextIpcRegistered) return; contextIpcRegistered = true; ipcMain.on('spellcheck-context-target', (event, payload: {isTextarea?: boolean}) => { contextSourceByWebContents.set(event.sender, { isTextarea: Boolean(payload?.isTextarea), ts: Date.now(), }); }); }; const pickSystemLanguages = (session: Session): Array => { const available = session.availableSpellCheckerLanguages ?? []; const availableMap = new Map(available.map((code) => [normalizeLanguage(code), code])); const preferred = (typeof app.getPreferredSystemLanguages === 'function' && app.getPreferredSystemLanguages()) || [ app.getLocale(), ]; const selected: Array = []; for (const lang of preferred) { const normalized = normalizeLanguage(lang); const exact = availableMap.get(normalized); if (exact) { selected.push(exact); } } if (selected.length > 0) return selected; if (available.length > 0) return [available[0]]; return []; }; const applyStateToSession = (session: Session, state: SpellcheckState): void => { session.setSpellCheckerEnabled(state.enabled); if (!isMac) { const languages = state.languages.length > 0 ? state.languages : pickSystemLanguages(session) || session.availableSpellCheckerLanguages; if (languages && languages.length > 0) { session.setSpellCheckerLanguages(languages); } } }; const shouldHandleContextMenu = (webContents: WebContents, params: Electron.ContextMenuParams): boolean => { if (!params.isEditable) return false; const inputFieldType = (params as {inputFieldType?: string}).inputFieldType; const isPassword = (params as {isPassword?: boolean}).isPassword === true || inputFieldType === 'password' || (params as {formControlType?: string}).formControlType === 'password'; if (isPassword) return false; const target = contextSourceByWebContents.get(webContents); const targetRecent = target && Date.now() - target.ts < 5000; const isTextLike = inputFieldType === 'plainText' || inputFieldType === 'textarea' || inputFieldType === undefined; return Boolean((targetRecent && target.isTextarea) || isTextLike); }; export const registerSpellcheck = (webContents: WebContents): void => { ensureContextIpc(); const session = webContents.session; let state: SpellcheckState = {...defaultState}; const pickLanguages = (langs: Array, electronSession: Electron.Session): Array => { if (langs.length > 0) { return langs; } if (!isMac) { return pickSystemLanguages(electronSession); } return []; }; const normalizeState = (incoming: RendererSpellcheckState | SpellcheckState): SpellcheckState => { const available = session.availableSpellCheckerLanguages ?? []; const availableSet = new Set(available.map(normalizeLanguage)); const langs = (incoming.languages ?? state.languages ?? []).filter((lang) => availableSet.has(normalizeLanguage(lang)), ); const pickedLanguages = pickLanguages(langs, session); return { enabled: incoming.enabled ?? state.enabled ?? defaultState.enabled, languages: pickedLanguages, }; }; const broadcastState = () => { webContents.send('spellcheck-state-changed', state); }; const setState = (next: RendererSpellcheckState | SpellcheckState, opts?: {broadcast?: boolean}) => { state = normalizeState(next); applyStateToSession(session, state); if (opts?.broadcast !== false) { broadcastState(); } }; setState(state, {broadcast: false}); ipcMain.handle('spellcheck-get-state', () => state); ipcMain.handle('spellcheck-set-state', (_event, next: RendererSpellcheckState) => { setState(next); return state; }); const openLanguageSettings = async () => { if (!showSpellMenu) return false; try { if (isMac) { await shell.openExternal('x-apple.systempreferences:com.apple.preference.keyboard'); return true; } if (isWindows) { await shell.openExternal('ms-settings:regionlanguage'); return true; } return false; } catch (error) { log.warn('[Spellcheck] Failed to open language settings', error); return false; } }; ipcMain.handle('spellcheck-get-available-languages', () => session.availableSpellCheckerLanguages ?? []); ipcMain.handle('spellcheck-open-language-settings', () => openLanguageSettings()); ipcMain.handle('spellcheck-replace-misspelling', (_event, replacement: string) => { webContents.replaceMisspelling(replacement); }); ipcMain.handle('spellcheck-add-word-to-dictionary', (_event, word: string) => { session.addWordToSpellCheckerDictionary(word); }); webContents.on('context-menu', (event, params) => { if (!shouldHandleContextMenu(webContents, params)) { return; } event.preventDefault(); const spellcheckEnabled = session.isSpellCheckerEnabled(); const misspelledWord = params.misspelledWord; const suggestions = params.dictionarySuggestions || []; webContents.send('textarea-context-menu', { misspelledWord: spellcheckEnabled ? misspelledWord : undefined, suggestions: spellcheckEnabled && misspelledWord ? suggestions : [], editFlags: { canUndo: params.editFlags.canUndo, canRedo: params.editFlags.canRedo, canCut: params.editFlags.canCut, canCopy: params.editFlags.canCopy, canPaste: params.editFlags.canPaste, canSelectAll: params.editFlags.canSelectAll, }, x: params.x, y: params.y, }); }); };