mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
1151 lines
38 KiB
TypeScript
1151 lines
38 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createHash} from 'node:crypto';
|
|
import * as fsSync from 'node:fs';
|
|
import * as fs from 'node:fs/promises';
|
|
import {createRequire} from 'node:module';
|
|
import * as path from 'node:path';
|
|
import {STATIC_CDN_URL} from '@electron/common/Constants';
|
|
import type {SpellcheckBundledDictionary} from '@electron/common/Types';
|
|
import {getSpellcheckLaunchMode, type SpellcheckLaunchMode} from '@electron/main/LaunchOptions';
|
|
import {getNativeLocale} from '@electron/main/MainI18n';
|
|
import {
|
|
BUNDLED_DICTIONARIES,
|
|
DICTIONARY_SOURCES,
|
|
type SpellcheckDictionaryCatalogEntry,
|
|
} from '@electron/main/SpellcheckDictionaries';
|
|
import {app, ipcMain, type Session, type WebContents} from 'electron';
|
|
import log from 'electron-log';
|
|
import {franc} from 'franc-min';
|
|
import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm';
|
|
|
|
const requireModule = createRequire(import.meta.url);
|
|
|
|
type SystemHunspellNative = {
|
|
Hunspell:
|
|
| (new (
|
|
affPath: string,
|
|
dicPath: string,
|
|
) => {
|
|
spell(word: string): boolean;
|
|
suggest(word: string, max?: number): Array<string>;
|
|
add(word: string): void;
|
|
remove(word: string): void;
|
|
close(): void;
|
|
})
|
|
| null;
|
|
discoverSystemDictionaries: (() => Array<{tag: string; affPath: string; dicPath: string}>) | null;
|
|
hashFile: ((path: string) => Promise<string>) | null;
|
|
loadError: Error | null;
|
|
};
|
|
|
|
let systemHunspellModule: SystemHunspellNative | null = null;
|
|
|
|
function loadSystemHunspell(): SystemHunspellNative | null {
|
|
if (process.platform !== 'linux') return null;
|
|
if (systemHunspellModule) return systemHunspellModule;
|
|
try {
|
|
const mod = requireModule('@fluxer/system-hunspell') as SystemHunspellNative;
|
|
if (!mod.Hunspell || !mod.discoverSystemDictionaries) {
|
|
throw mod.loadError ?? new Error('@fluxer/system-hunspell exports missing');
|
|
}
|
|
systemHunspellModule = mod;
|
|
return mod;
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] @fluxer/system-hunspell unavailable; falling back to hunspell-asm on Linux', {error});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type SpellcheckEngine = 'auto' | 'hunspell' | 'system';
|
|
|
|
interface SpellcheckState {
|
|
enabled: boolean;
|
|
engine: SpellcheckEngine;
|
|
autoDetect: boolean;
|
|
languages: Array<string>;
|
|
personalDictionary: Array<string>;
|
|
}
|
|
|
|
interface RendererSpellcheckPatch {
|
|
enabled?: boolean;
|
|
engine?: SpellcheckEngine;
|
|
autoDetect?: boolean;
|
|
languages?: Array<string>;
|
|
personalDictionary?: Array<string>;
|
|
}
|
|
|
|
interface SpellcheckDictionaryData {
|
|
aff: Uint8Array;
|
|
dic: Uint8Array;
|
|
}
|
|
|
|
interface ResolvedEngine {
|
|
mode: 'hunspell' | 'system' | 'off';
|
|
hunspellLangs: Array<string>;
|
|
systemLangs: Array<string>;
|
|
allowSystemHunspell?: boolean;
|
|
preferSystemHunspell?: boolean;
|
|
}
|
|
|
|
interface LoadedHunspell {
|
|
tag: string;
|
|
hunspell: Hunspell;
|
|
affPath: string;
|
|
dicPath: string;
|
|
}
|
|
|
|
const STATIC_CDN_ENDPOINT = process.env.FLUXER_STATIC_CDN_ENDPOINT?.replace(/\/+$/, '') ?? '';
|
|
const DICTIONARY_DOWNLOAD_BASE_URL =
|
|
process.env.FLUXER_SPELLCHECK_DICTIONARY_BASE_URL ||
|
|
`${STATIC_CDN_ENDPOINT || STATIC_CDN_URL}/desktop/spellcheck/dictionaries`;
|
|
const DICTIONARY_CACHE_VERSION = 1;
|
|
const DICTIONARY_DOWNLOAD_TIMEOUT_MS = 15000;
|
|
const DICTIONARY_RETRY_DELAYS_MS: ReadonlyArray<number> = [5000, 15000, 45000];
|
|
const AUTODETECT_MAX_TEXT_LENGTH = 2000;
|
|
const AUTODETECT_PREFIX_SKIP_CHARS = 20;
|
|
const AUTODETECT_CONTEXT_MAX_LENGTH = 128;
|
|
const BUILTIN_ALLOWLIST: ReadonlyArray<string> = ['Fluxer', 'fluxer', 'Hampus'];
|
|
const isLinux = process.platform === 'linux';
|
|
const isMac = process.platform === 'darwin';
|
|
const useChromiumSpellchecker = !isLinux;
|
|
const defaultState: SpellcheckState = {
|
|
enabled: true,
|
|
engine: 'auto',
|
|
autoDetect: true,
|
|
languages: [],
|
|
personalDictionary: [],
|
|
};
|
|
const sessionState = new WeakMap<Session, SpellcheckState>();
|
|
const sessionResolvedEngine = new WeakMap<Session, ResolvedEngine>();
|
|
const sessionLoaded = new WeakMap<Session, Map<string, LoadedHunspell>>();
|
|
const sessionPersonalApplied = new WeakMap<Session, Set<string>>();
|
|
const sessionApplyGeneration = new WeakMap<Session, number>();
|
|
const sessionAutoDetectedLanguage = new WeakMap<Session, string | null>();
|
|
const autodetectCacheByWebContents = new WeakMap<
|
|
WebContents,
|
|
Map<
|
|
string,
|
|
{
|
|
text: string;
|
|
language: string | null;
|
|
}
|
|
>
|
|
>();
|
|
const sessionRetryTimer = new WeakMap<Session, NodeJS.Timeout>();
|
|
const sessionRetryAttempt = new WeakMap<Session, number>();
|
|
const dictionaryDataPromises = new Map<string, Promise<SpellcheckDictionaryData | null>>();
|
|
|
|
let factoryPromise: Promise<HunspellFactory> | null = null;
|
|
let ipcRegistered = false;
|
|
let launchModeLogged = false;
|
|
|
|
const contextSourceByWebContents = new WeakMap<
|
|
WebContents,
|
|
{
|
|
isTextarea: boolean;
|
|
ts: number;
|
|
}
|
|
>();
|
|
const norm = (code: string): string => code.toLowerCase();
|
|
const beginSessionApply = (session: Session): number => {
|
|
const generation = (sessionApplyGeneration.get(session) ?? 0) + 1;
|
|
sessionApplyGeneration.set(session, generation);
|
|
return generation;
|
|
};
|
|
const isSessionApplyCurrent = (session: Session, generation: number): boolean =>
|
|
sessionApplyGeneration.get(session) === generation;
|
|
const dictionaryByTag = new Map<string, SpellcheckDictionaryCatalogEntry>(
|
|
BUNDLED_DICTIONARIES.map((d) => [norm(d.tag), d]),
|
|
);
|
|
const resolveBundledFor = (tag: string): SpellcheckDictionaryCatalogEntry | null => {
|
|
const lower = norm(tag);
|
|
const exact = dictionaryByTag.get(lower);
|
|
if (exact) return exact;
|
|
const base = lower.split('-')[0];
|
|
return dictionaryByTag.get(base) ?? null;
|
|
};
|
|
const listBundled = (): Array<SpellcheckBundledDictionary> =>
|
|
BUNDLED_DICTIONARIES.map((dict) => ({
|
|
tag: dict.tag,
|
|
package: dict.package,
|
|
displayName: dict.displayName,
|
|
nativeName: dict.nativeName,
|
|
}));
|
|
const ensureFactory = (): Promise<HunspellFactory> => {
|
|
if (!factoryPromise) {
|
|
factoryPromise = loadModule().catch((error) => {
|
|
factoryPromise = null;
|
|
log.error('[Spellcheck] Failed to load hunspell-asm', error);
|
|
throw error;
|
|
});
|
|
}
|
|
return factoryPromise;
|
|
};
|
|
const dictionaryCacheKey = (dict: SpellcheckDictionaryCatalogEntry): string =>
|
|
`${dict.package}@${DICTIONARY_SOURCES[dict.package].version}`;
|
|
const dictionaryCacheDir = (dict: SpellcheckDictionaryCatalogEntry): string =>
|
|
path.join(
|
|
app.getPath('userData'),
|
|
'spellcheck-dictionaries',
|
|
`v${DICTIONARY_CACHE_VERSION}`,
|
|
dictionaryCacheKey(dict),
|
|
);
|
|
|
|
export function cleanupLinuxChromiumSpellcheckDictionaries(userDataPath: string): void {
|
|
if (!isLinux) return;
|
|
const chromiumDictionaryDir = path.join(userDataPath, 'Dictionaries');
|
|
if (!fsSync.existsSync(chromiumDictionaryDir)) return;
|
|
try {
|
|
fsSync.rmSync(chromiumDictionaryDir, {recursive: true, force: true});
|
|
log.info('[Spellcheck] Removed Chromium spellcheck dictionary cache on Linux', {path: chromiumDictionaryDir});
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] Failed to remove Chromium spellcheck dictionary cache on Linux', {
|
|
path: chromiumDictionaryDir,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
const dictionaryFileUrl = (dict: SpellcheckDictionaryCatalogEntry, fileName: 'index.aff' | 'index.dic'): string => {
|
|
if (!DICTIONARY_DOWNLOAD_BASE_URL) {
|
|
throw new Error('Set FLUXER_SPELLCHECK_DICTIONARY_BASE_URL or FLUXER_STATIC_CDN_ENDPOINT to download dictionaries');
|
|
}
|
|
const base = DICTIONARY_DOWNLOAD_BASE_URL.replace(/\/+$/, '');
|
|
return `${base}/${dict.package}@${DICTIONARY_SOURCES[dict.package].version}/${fileName}`;
|
|
};
|
|
const sha256 = (data: Uint8Array): string => createHash('sha256').update(data).digest('hex');
|
|
const verifyDictionaryFile = (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
fileName: 'index.aff' | 'index.dic',
|
|
data: Uint8Array,
|
|
): boolean => {
|
|
const source = DICTIONARY_SOURCES[dict.package];
|
|
const expectedBytes = fileName === 'index.aff' ? source.affBytes : source.dicBytes;
|
|
const expectedSha256 = fileName === 'index.aff' ? source.affSha256 : source.dicSha256;
|
|
return data.byteLength === expectedBytes && sha256(data) === expectedSha256;
|
|
};
|
|
const sha256OfPath = async (filePath: string): Promise<string> => {
|
|
return sha256(await fs.readFile(filePath));
|
|
};
|
|
const readCachedDictionaryFile = async (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
fileName: 'index.aff' | 'index.dic',
|
|
): Promise<Buffer | null> => {
|
|
const filePath = path.join(dictionaryCacheDir(dict), fileName);
|
|
try {
|
|
const stat = await fs.stat(filePath);
|
|
const source = DICTIONARY_SOURCES[dict.package];
|
|
const expectedBytes = fileName === 'index.aff' ? source.affBytes : source.dicBytes;
|
|
const expectedSha256 = fileName === 'index.aff' ? source.affSha256 : source.dicSha256;
|
|
if (stat.size !== expectedBytes) {
|
|
await fs.rm(filePath, {force: true});
|
|
log.warn(`[Spellcheck] Removed size-mismatched cached dictionary file ${dictionaryCacheKey(dict)}/${fileName}`);
|
|
return null;
|
|
}
|
|
if ((await sha256OfPath(filePath)) !== expectedSha256) {
|
|
await fs.rm(filePath, {force: true});
|
|
log.warn(`[Spellcheck] Removed hash-mismatched cached dictionary file ${dictionaryCacheKey(dict)}/${fileName}`);
|
|
return null;
|
|
}
|
|
return await fs.readFile(filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
const writeCachedDictionaryFile = async (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
fileName: 'index.aff' | 'index.dic',
|
|
data: Uint8Array,
|
|
): Promise<void> => {
|
|
const dir = dictionaryCacheDir(dict);
|
|
const filePath = path.join(dir, fileName);
|
|
const tmpPath = path.join(dir, `.${fileName}.${process.pid}.${Date.now()}.tmp`);
|
|
await fs.mkdir(dir, {recursive: true});
|
|
await fs.writeFile(tmpPath, data, {mode: 0o600});
|
|
try {
|
|
await fs.rename(tmpPath, filePath);
|
|
} catch (error) {
|
|
await fs.rm(tmpPath, {force: true}).catch(() => {});
|
|
throw error;
|
|
}
|
|
};
|
|
const downloadDictionaryFile = async (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
fileName: 'index.aff' | 'index.dic',
|
|
): Promise<Buffer> => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(dictionaryFileUrl(dict, fileName), {
|
|
headers: {
|
|
Accept: 'application/octet-stream,*/*;q=0.8',
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = Buffer.from(await response.arrayBuffer());
|
|
if (!verifyDictionaryFile(dict, fileName, data)) {
|
|
throw new Error(`sha256/size mismatch for ${dictionaryCacheKey(dict)}/${fileName}`);
|
|
}
|
|
await writeCachedDictionaryFile(dict, fileName, data);
|
|
return data;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
const loadLocalDictionaryPackage = async (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
): Promise<SpellcheckDictionaryData | null> => {
|
|
try {
|
|
const mod = await opaqueImport(dict.package);
|
|
const data = {
|
|
aff: Buffer.from(mod.default.aff),
|
|
dic: Buffer.from(mod.default.dic),
|
|
};
|
|
if (!verifyDictionaryFile(dict, 'index.aff', data.aff) || !verifyDictionaryFile(dict, 'index.dic', data.dic)) {
|
|
log.warn(`[Spellcheck] Ignoring local dictionary package with unexpected contents: ${dictionaryCacheKey(dict)}`);
|
|
return null;
|
|
}
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const loadLazyDictionaryData = async (
|
|
dict: SpellcheckDictionaryCatalogEntry,
|
|
): Promise<SpellcheckDictionaryData | null> => {
|
|
const key = dictionaryCacheKey(dict);
|
|
const existing = dictionaryDataPromises.get(key);
|
|
if (existing) return existing;
|
|
const promise = (async () => {
|
|
const cachedAff = await readCachedDictionaryFile(dict, 'index.aff');
|
|
const cachedDic = await readCachedDictionaryFile(dict, 'index.dic');
|
|
if (cachedAff && cachedDic) {
|
|
return {aff: cachedAff, dic: cachedDic};
|
|
}
|
|
const local = await loadLocalDictionaryPackage(dict);
|
|
if (local) return local;
|
|
const [aff, dic] = await Promise.all([
|
|
cachedAff ?? downloadDictionaryFile(dict, 'index.aff'),
|
|
cachedDic ?? downloadDictionaryFile(dict, 'index.dic'),
|
|
]);
|
|
return {aff, dic};
|
|
})()
|
|
.catch((error) => {
|
|
log.warn(`[Spellcheck] Failed to lazily load dictionary ${key}`, error);
|
|
return null;
|
|
})
|
|
.finally(() => {
|
|
dictionaryDataPromises.delete(key);
|
|
});
|
|
dictionaryDataPromises.set(key, promise);
|
|
return promise;
|
|
};
|
|
const opaqueImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<{
|
|
default: {
|
|
aff: Uint8Array;
|
|
dic: Uint8Array;
|
|
};
|
|
}>;
|
|
const loadHunspellForTag = async (
|
|
tag: string,
|
|
shouldContinue: () => boolean,
|
|
allowSystemFallback: boolean,
|
|
preferSystem: boolean,
|
|
): Promise<LoadedHunspell | null> => {
|
|
if (preferSystem) {
|
|
const native = loadFromSystemHunspell(tag);
|
|
if (native) return native;
|
|
}
|
|
const dict = resolveBundledFor(tag);
|
|
if (dict) {
|
|
try {
|
|
const data = await loadLazyDictionaryData(dict);
|
|
if (!shouldContinue()) return null;
|
|
if (data) {
|
|
const factory = await ensureFactory();
|
|
if (!shouldContinue()) return null;
|
|
const {aff, dic} = data;
|
|
const affPath = factory.mountBuffer(aff, `${dict.tag}.aff`);
|
|
const dicPath = factory.mountBuffer(dic, `${dict.tag}.dic`);
|
|
const hunspell = factory.create(affPath, dicPath);
|
|
for (const word of BUILTIN_ALLOWLIST) {
|
|
hunspell.addWord(word);
|
|
}
|
|
log.info(`[Spellcheck] Loaded bundled hunspell dictionary for "${tag}" from ${dict.package}`);
|
|
return {tag: dict.tag, hunspell, affPath, dicPath};
|
|
}
|
|
} catch (error) {
|
|
log.warn(`[Spellcheck] Failed to load Hunspell dictionary "${dict.package}"`, error);
|
|
}
|
|
if (isLinux) {
|
|
return allowSystemFallback ? loadFromSystemHunspell(tag) : null;
|
|
}
|
|
}
|
|
return allowSystemFallback || !isLinux ? loadFromSystemHunspell(tag) : null;
|
|
};
|
|
const NATIVE_AFF_PATH_TAG = '__native__';
|
|
|
|
function loadFromSystemHunspell(tag: string): LoadedHunspell | null {
|
|
const mod = loadSystemHunspell();
|
|
if (!mod?.Hunspell || !mod.discoverSystemDictionaries) return null;
|
|
const wantedKey = norm(tag);
|
|
const wantedBase = languageBase(tag);
|
|
const installed = mod.discoverSystemDictionaries();
|
|
const match =
|
|
installed.find((entry) => norm(entry.tag) === wantedKey) ??
|
|
installed.find((entry) => languageBase(entry.tag) === wantedBase);
|
|
if (!match) return null;
|
|
try {
|
|
const native = new mod.Hunspell(match.affPath, match.dicPath);
|
|
for (const word of BUILTIN_ALLOWLIST) {
|
|
try {
|
|
native.add(word);
|
|
} catch {}
|
|
}
|
|
const adapter = {
|
|
spell(word: string) {
|
|
return native.spell(word);
|
|
},
|
|
suggest(word: string) {
|
|
return native.suggest(word, 12);
|
|
},
|
|
addWord(word: string) {
|
|
native.add(word);
|
|
},
|
|
removeWord(word: string) {
|
|
native.remove(word);
|
|
},
|
|
dispose() {
|
|
native.close();
|
|
},
|
|
} as unknown as Hunspell;
|
|
log.info(`[Spellcheck] Loaded system hunspell dictionary for "${tag}" from ${match.affPath}`);
|
|
return {tag, hunspell: adapter, affPath: NATIVE_AFF_PATH_TAG, dicPath: NATIVE_AFF_PATH_TAG};
|
|
} catch (error) {
|
|
log.warn(`[Spellcheck] Native hunspell failed for "${tag}"; will retry hunspell-asm`, {error});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const isNativeLoaded = (loaded: LoadedHunspell): boolean => loaded.affPath === NATIVE_AFF_PATH_TAG;
|
|
const disposeLoaded = (loaded: LoadedHunspell, factory: HunspellFactory | null): void => {
|
|
try {
|
|
loaded.hunspell.dispose();
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] dispose failed', error);
|
|
}
|
|
if (isNativeLoaded(loaded)) return;
|
|
if (!factory) {
|
|
log.warn('[Spellcheck] Hunspell WASM dictionary disposed without unmounting buffers');
|
|
return;
|
|
}
|
|
try {
|
|
factory.unmount(loaded.affPath);
|
|
} catch {}
|
|
try {
|
|
factory.unmount(loaded.dicPath);
|
|
} catch {}
|
|
};
|
|
const detectPreferredLanguages = (session: Session): Array<string> => {
|
|
const electronAvailable = useChromiumSpellchecker ? (session.availableSpellCheckerLanguages ?? []) : [];
|
|
const fromOs = (typeof app.getPreferredSystemLanguages === 'function' && app.getPreferredSystemLanguages()) || [];
|
|
const preferred = [getNativeLocale(), ...fromOs, app.getLocale()];
|
|
const seen = new Set<string>();
|
|
const result: Array<string> = [];
|
|
for (const raw of preferred) {
|
|
const lower = norm(raw);
|
|
if (seen.has(lower)) continue;
|
|
seen.add(lower);
|
|
const exact = electronAvailable.find((c) => norm(c) === lower);
|
|
if (exact) {
|
|
result.push(exact);
|
|
} else {
|
|
result.push(raw);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
const ISO6393_TO_DICTIONARY_BASE: Record<string, string> = {
|
|
bul: 'bg',
|
|
cat: 'ca',
|
|
ces: 'cs',
|
|
dan: 'da',
|
|
deu: 'de',
|
|
ell: 'el',
|
|
eng: 'en',
|
|
epo: 'eo',
|
|
est: 'et',
|
|
eus: 'eu',
|
|
fas: 'fa',
|
|
fra: 'fr',
|
|
glg: 'gl',
|
|
heb: 'he',
|
|
hrv: 'hr',
|
|
hun: 'hu',
|
|
isl: 'is',
|
|
ita: 'it',
|
|
kor: 'ko',
|
|
lav: 'lv',
|
|
lit: 'lt',
|
|
nld: 'nl',
|
|
nob: 'nb',
|
|
pol: 'pl',
|
|
por: 'pt',
|
|
ron: 'ro',
|
|
rus: 'ru',
|
|
slk: 'sk',
|
|
slv: 'sl',
|
|
spa: 'es',
|
|
srp: 'sr',
|
|
swe: 'sv',
|
|
tur: 'tr',
|
|
ukr: 'uk',
|
|
vie: 'vi',
|
|
};
|
|
const AUTODETECT_SUPPORTED_ISO6393 = Object.keys(ISO6393_TO_DICTIONARY_BASE);
|
|
const languageBase = (tag: string): string => norm(tag).split('-')[0];
|
|
const chooseBundledTagForLanguage = (language: string, session: Session): string | null => {
|
|
const lower = languageBase(language);
|
|
if (lower === 'en') {
|
|
for (const preferred of detectPreferredLanguages(session)) {
|
|
const dict = resolveBundledFor(preferred);
|
|
if (dict && norm(dict.tag) === 'en-gb') {
|
|
return dict.tag;
|
|
}
|
|
}
|
|
return resolveBundledFor('en-US')?.tag ?? resolveBundledFor('en')?.tag ?? null;
|
|
}
|
|
for (const preferred of detectPreferredLanguages(session)) {
|
|
const dict = resolveBundledFor(preferred);
|
|
if (dict && languageBase(dict.tag) === lower) {
|
|
return dict.tag;
|
|
}
|
|
}
|
|
return resolveBundledFor(lower)?.tag ?? null;
|
|
};
|
|
const detectLanguageFromText = (text: string, session: Session): string | null => {
|
|
const code = franc(text.slice(0, AUTODETECT_MAX_TEXT_LENGTH), {
|
|
minLength: 3,
|
|
only: AUTODETECT_SUPPORTED_ISO6393,
|
|
});
|
|
const dictionaryBase = ISO6393_TO_DICTIONARY_BASE[code];
|
|
if (!dictionaryBase) return null;
|
|
return chooseBundledTagForLanguage(dictionaryBase, session);
|
|
};
|
|
const isTinyAutodetectPrefixChange = (previous: string, next: string): boolean => {
|
|
if (previous.length === 0 || next.length === 0) return false;
|
|
if (Math.abs(next.length - previous.length) >= AUTODETECT_PREFIX_SKIP_CHARS) return false;
|
|
return next.startsWith(previous) || previous.startsWith(next);
|
|
};
|
|
const getAutodetectContextKey = (value: unknown): string => {
|
|
if (typeof value !== 'string') return 'default';
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) return 'default';
|
|
return trimmed.slice(0, AUTODETECT_CONTEXT_MAX_LENGTH);
|
|
};
|
|
const detectLanguageFromContextText = (webContents: WebContents, contextKey: string, text: string): string | null => {
|
|
let cache = autodetectCacheByWebContents.get(webContents);
|
|
if (!cache) {
|
|
cache = new Map();
|
|
autodetectCacheByWebContents.set(webContents, cache);
|
|
}
|
|
const previous = cache.get(contextKey);
|
|
if (previous && (previous.text === text || isTinyAutodetectPrefixChange(previous.text, text))) {
|
|
return previous.language;
|
|
}
|
|
const language = text.trim().length === 0 ? null : detectLanguageFromText(text, webContents.session);
|
|
cache.set(contextKey, {text, language});
|
|
return language;
|
|
};
|
|
const sanitizeEngine = (value: unknown): SpellcheckEngine => {
|
|
if (value === 'hunspell' || value === 'system' || value === 'auto') return value;
|
|
return defaultState.engine;
|
|
};
|
|
const normalizeState = (prev: SpellcheckState, patch: RendererSpellcheckPatch | SpellcheckState): SpellcheckState => {
|
|
const next: SpellcheckState = {
|
|
enabled: patch.enabled ?? prev.enabled,
|
|
engine: sanitizeEngine(patch.engine ?? prev.engine),
|
|
autoDetect: patch.autoDetect ?? prev.autoDetect,
|
|
languages: Array.isArray(patch.languages) ? [...patch.languages] : [...prev.languages],
|
|
personalDictionary: Array.isArray(patch.personalDictionary)
|
|
? [...patch.personalDictionary]
|
|
: [...prev.personalDictionary],
|
|
};
|
|
const seen = new Set<string>();
|
|
next.languages = next.languages.filter((tag) => {
|
|
if (typeof tag !== 'string' || tag.length === 0) return false;
|
|
const key = norm(tag);
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
const seenWords = new Set<string>();
|
|
next.personalDictionary = next.personalDictionary
|
|
.map((w) => (typeof w === 'string' ? w.trim() : ''))
|
|
.filter((w) => {
|
|
if (w.length === 0) return false;
|
|
if (seenWords.has(w)) return false;
|
|
seenWords.add(w);
|
|
return true;
|
|
});
|
|
return next;
|
|
};
|
|
const applyLaunchSpellcheckMode = (state: SpellcheckState): SpellcheckState => {
|
|
const mode: SpellcheckLaunchMode = getSpellcheckLaunchMode(process.argv);
|
|
if (mode === 'default') return state;
|
|
if (mode === 'off') {
|
|
return {...state, enabled: false};
|
|
}
|
|
return {...state, enabled: true, engine: mode};
|
|
};
|
|
const requestedLanguages = (state: SpellcheckState, session: Session): Array<string> => {
|
|
if (!state.autoDetect) return state.languages;
|
|
const detected = sessionAutoDetectedLanguage.get(session);
|
|
const seen = new Set<string>();
|
|
const requested: Array<string> = [];
|
|
for (const tag of detected ? [detected, ...detectPreferredLanguages(session)] : detectPreferredLanguages(session)) {
|
|
const key = norm(tag);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
requested.push(tag);
|
|
}
|
|
return requested;
|
|
};
|
|
const resolveSystemLanguages = (session: Session, requested: Array<string>): Array<string> => {
|
|
const sysOnly: Array<string> = [];
|
|
const seen = new Set<string>();
|
|
for (const tag of requested) {
|
|
const lower = norm(tag);
|
|
const match =
|
|
(session.availableSpellCheckerLanguages ?? []).find((c) => norm(c) === lower) ||
|
|
(session.availableSpellCheckerLanguages ?? []).find((c) => norm(c).split('-')[0] === lower.split('-')[0]) ||
|
|
null;
|
|
if (match && !seen.has(norm(match))) {
|
|
seen.add(norm(match));
|
|
sysOnly.push(match);
|
|
}
|
|
}
|
|
return sysOnly;
|
|
};
|
|
const resolveSystemFallback = (state: SpellcheckState, session: Session): ResolvedEngine => ({
|
|
mode: 'system',
|
|
hunspellLangs: [],
|
|
systemLangs: resolveSystemLanguages(session, requestedLanguages(state, session)),
|
|
});
|
|
const resolveEngine = (state: SpellcheckState, session: Session): ResolvedEngine => {
|
|
if (!state.enabled) {
|
|
return {mode: 'off', hunspellLangs: [], systemLangs: []};
|
|
}
|
|
const requested = requestedLanguages(state, session);
|
|
const bundled: Array<string> = [];
|
|
const seenBundled = new Set<string>();
|
|
for (const tag of requested) {
|
|
const dict = resolveBundledFor(tag);
|
|
if (dict) {
|
|
const key = norm(dict.tag);
|
|
if (!seenBundled.has(key)) {
|
|
seenBundled.add(key);
|
|
bundled.push(dict.tag);
|
|
}
|
|
}
|
|
}
|
|
if (state.engine === 'hunspell') {
|
|
return {mode: 'hunspell', hunspellLangs: bundled, systemLangs: []};
|
|
}
|
|
if (!isLinux) {
|
|
return {mode: 'system', hunspellLangs: [], systemLangs: resolveSystemLanguages(session, requested)};
|
|
}
|
|
return {
|
|
mode: 'hunspell',
|
|
hunspellLangs: bundled.length > 0 ? bundled : requested,
|
|
systemLangs: [],
|
|
allowSystemHunspell: true,
|
|
preferSystemHunspell: true,
|
|
};
|
|
};
|
|
const applyResolvedToSession = async (
|
|
session: Session,
|
|
state: SpellcheckState,
|
|
resolved: ResolvedEngine,
|
|
shouldContinue: () => boolean,
|
|
): Promise<ResolvedEngine> => {
|
|
if (resolved.mode === 'off') {
|
|
if (!shouldContinue()) return resolved;
|
|
await unloadAll(session);
|
|
if (!shouldContinue()) return resolved;
|
|
if (useChromiumSpellchecker) {
|
|
session.setSpellCheckerEnabled(false);
|
|
}
|
|
return resolved;
|
|
}
|
|
if (resolved.mode === 'system') {
|
|
if (!shouldContinue()) return resolved;
|
|
await unloadAll(session);
|
|
if (!shouldContinue()) return resolved;
|
|
applySystemSpellcheck(session, resolved.systemLangs);
|
|
return resolved;
|
|
}
|
|
if (!shouldContinue()) return resolved;
|
|
if (useChromiumSpellchecker) {
|
|
session.setSpellCheckerEnabled(true);
|
|
}
|
|
if (useChromiumSpellchecker && !isMac) {
|
|
try {
|
|
session.setSpellCheckerLanguages([]);
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] clearing setSpellCheckerLanguages failed', error);
|
|
}
|
|
}
|
|
const loadedCount = await syncLoadedHunspell(
|
|
session,
|
|
resolved.hunspellLangs,
|
|
shouldContinue,
|
|
resolved.allowSystemHunspell === true,
|
|
resolved.preferSystemHunspell === true,
|
|
);
|
|
if (!shouldContinue()) return resolved;
|
|
if (loadedCount === 0) {
|
|
if (!useChromiumSpellchecker) {
|
|
log.warn('[Spellcheck] No Hunspell dictionaries loaded; Chromium spellcheck fallback is disabled on Linux');
|
|
return {mode: 'off', hunspellLangs: [], systemLangs: []};
|
|
}
|
|
const fallback = resolveSystemFallback(state, session);
|
|
log.warn('[Spellcheck] No Hunspell dictionaries loaded; falling back to the system spellchecker');
|
|
await unloadAll(session);
|
|
if (!shouldContinue()) return fallback;
|
|
applySystemSpellcheck(session, fallback.systemLangs);
|
|
return fallback;
|
|
}
|
|
applyPersonalDictionary(session, state.personalDictionary);
|
|
return resolved;
|
|
};
|
|
const cancelDictionaryRetry = (session: Session): void => {
|
|
const timer = sessionRetryTimer.get(session);
|
|
if (!timer) return;
|
|
clearTimeout(timer);
|
|
sessionRetryTimer.delete(session);
|
|
};
|
|
const scheduleDictionaryRetry = (webContents: WebContents): void => {
|
|
const session = webContents.session;
|
|
const attempt = sessionRetryAttempt.get(session) ?? 0;
|
|
const delay = DICTIONARY_RETRY_DELAYS_MS[attempt];
|
|
if (delay === undefined) return;
|
|
sessionRetryAttempt.set(session, attempt + 1);
|
|
cancelDictionaryRetry(session);
|
|
const timer = setTimeout(() => {
|
|
sessionRetryTimer.delete(session);
|
|
if (webContents.isDestroyed()) return;
|
|
const current = sessionState.get(session);
|
|
if (!current?.enabled) return;
|
|
log.info('[Spellcheck] Retrying dictionary load after an empty resolve', {attempt: attempt + 1});
|
|
void applyStateToWebContents(webContents, current, {broadcastResolved: true});
|
|
}, delay);
|
|
timer.unref?.();
|
|
sessionRetryTimer.set(session, timer);
|
|
};
|
|
const sendResolvedEngine = (webContents: WebContents, effective: ResolvedEngine): void => {
|
|
try {
|
|
webContents.send('spellcheck-engine-resolved', {
|
|
mode: effective.mode,
|
|
hunspellLangs: effective.hunspellLangs,
|
|
systemLangs: effective.systemLangs,
|
|
});
|
|
} catch {}
|
|
};
|
|
const applyStateToWebContents = async (
|
|
webContents: WebContents,
|
|
state: SpellcheckState,
|
|
opts: {
|
|
broadcastState?: boolean;
|
|
broadcastResolved?: boolean;
|
|
} = {},
|
|
): Promise<ResolvedEngine> => {
|
|
const session = webContents.session;
|
|
const resolved = resolveEngine(state, session);
|
|
let effective = resolved;
|
|
const generation = beginSessionApply(session);
|
|
try {
|
|
effective = await applyResolvedToSession(session, state, resolved, () =>
|
|
isSessionApplyCurrent(session, generation),
|
|
);
|
|
} catch (error) {
|
|
log.error('[Spellcheck] apply failed', error);
|
|
}
|
|
if (!isSessionApplyCurrent(session, generation)) {
|
|
const settled = sessionResolvedEngine.get(session);
|
|
if (opts.broadcastResolved && settled) {
|
|
sendResolvedEngine(webContents, settled);
|
|
}
|
|
return effective;
|
|
}
|
|
sessionResolvedEngine.set(session, effective);
|
|
if (state.enabled && effective.mode === 'off') {
|
|
scheduleDictionaryRetry(webContents);
|
|
} else {
|
|
sessionRetryAttempt.delete(session);
|
|
cancelDictionaryRetry(session);
|
|
}
|
|
if (opts.broadcastState) {
|
|
try {
|
|
webContents.send('spellcheck-state-changed', state);
|
|
} catch {}
|
|
}
|
|
if (opts.broadcastResolved) {
|
|
sendResolvedEngine(webContents, effective);
|
|
}
|
|
return effective;
|
|
};
|
|
const applySystemSpellcheck = (session: Session, languages: Array<string>): void => {
|
|
if (!useChromiumSpellchecker) return;
|
|
session.setSpellCheckerEnabled(true);
|
|
if (languages.length > 0 && !isMac) {
|
|
try {
|
|
session.setSpellCheckerLanguages(languages);
|
|
} catch (error) {
|
|
log.warn('[Spellcheck] setSpellCheckerLanguages failed in system mode', error);
|
|
}
|
|
}
|
|
};
|
|
const listAvailableSpellcheckLanguages = (session: Session): Array<string> => {
|
|
if (!isLinux) {
|
|
return session.availableSpellCheckerLanguages ?? [];
|
|
}
|
|
const state = sessionState.get(session) ?? defaultState;
|
|
const launchMode = getSpellcheckLaunchMode(process.argv);
|
|
const effectiveEngine = launchMode === 'default' || launchMode === 'off' ? state.engine : launchMode;
|
|
const includeSystem = effectiveEngine !== 'hunspell';
|
|
const seen = new Set<string>();
|
|
const languages: Array<string> = [];
|
|
const add = (tag: string): void => {
|
|
const key = norm(tag);
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
languages.push(tag);
|
|
};
|
|
if (includeSystem) {
|
|
const native = loadSystemHunspell();
|
|
for (const entry of native?.discoverSystemDictionaries?.() ?? []) {
|
|
add(entry.tag);
|
|
}
|
|
}
|
|
for (const dict of BUNDLED_DICTIONARIES) {
|
|
add(dict.tag);
|
|
}
|
|
return languages;
|
|
};
|
|
const unloadAll = async (session: Session): Promise<void> => {
|
|
const loaded = sessionLoaded.get(session);
|
|
if (!loaded || loaded.size === 0) return;
|
|
let factory: HunspellFactory | null = null;
|
|
for (const entry of loaded.values()) {
|
|
if (!isNativeLoaded(entry)) {
|
|
factory ??= await ensureFactory().catch(() => null);
|
|
}
|
|
disposeLoaded(entry, factory);
|
|
}
|
|
loaded.clear();
|
|
sessionPersonalApplied.delete(session);
|
|
};
|
|
const syncLoadedHunspell = async (
|
|
session: Session,
|
|
wanted: Array<string>,
|
|
shouldContinue: () => boolean,
|
|
allowSystemFallback: boolean,
|
|
preferSystem: boolean,
|
|
): Promise<number> => {
|
|
if (wanted.length === 0) {
|
|
if (!shouldContinue()) return sessionLoaded.get(session)?.size ?? 0;
|
|
await unloadAll(session);
|
|
return 0;
|
|
}
|
|
const map = sessionLoaded.get(session) ?? new Map<string, LoadedHunspell>();
|
|
sessionLoaded.set(session, map);
|
|
const wantedKeys = new Set(wanted.map(norm));
|
|
const applied = sessionPersonalApplied.get(session);
|
|
let factory: HunspellFactory | null = null;
|
|
for (const [key, entry] of map.entries()) {
|
|
if (!wantedKeys.has(norm(entry.tag))) {
|
|
if (!isNativeLoaded(entry)) {
|
|
factory ??= await ensureFactory().catch(() => null);
|
|
}
|
|
disposeLoaded(entry, factory);
|
|
map.delete(key);
|
|
}
|
|
}
|
|
for (const tag of wanted) {
|
|
const key = norm(tag);
|
|
if (map.has(key)) continue;
|
|
const entry = await loadHunspellForTag(tag, shouldContinue, allowSystemFallback, preferSystem);
|
|
if (!shouldContinue()) {
|
|
if (entry) {
|
|
if (!isNativeLoaded(entry)) {
|
|
factory ??= await ensureFactory().catch(() => null);
|
|
}
|
|
disposeLoaded(entry, factory);
|
|
}
|
|
break;
|
|
}
|
|
if (entry) {
|
|
if (applied) {
|
|
for (const word of applied) {
|
|
try {
|
|
entry.hunspell.addWord(word);
|
|
} catch {}
|
|
}
|
|
}
|
|
map.set(key, entry);
|
|
}
|
|
}
|
|
return map.size;
|
|
};
|
|
const applyPersonalDictionary = (session: Session, words: Array<string>): void => {
|
|
const loaded = sessionLoaded.get(session);
|
|
if (!loaded || loaded.size === 0) return;
|
|
const previous = sessionPersonalApplied.get(session) ?? new Set<string>();
|
|
const desired = new Set(words);
|
|
for (const w of previous) {
|
|
if (!desired.has(w)) {
|
|
for (const entry of loaded.values()) {
|
|
try {
|
|
entry.hunspell.removeWord(w);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
for (const w of desired) {
|
|
if (!previous.has(w)) {
|
|
for (const entry of loaded.values()) {
|
|
try {
|
|
entry.hunspell.addWord(w);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
sessionPersonalApplied.set(session, desired);
|
|
};
|
|
const checkWords = (session: Session, words: Array<string>): Array<string> => {
|
|
const loaded = sessionLoaded.get(session);
|
|
if (!loaded || loaded.size === 0) return [];
|
|
const result: Array<string> = [];
|
|
const instances = [...loaded.values()];
|
|
for (const word of words) {
|
|
if (typeof word !== 'string' || word.length === 0) continue;
|
|
let ok = false;
|
|
for (const entry of instances) {
|
|
try {
|
|
if (entry.hunspell.spell(word)) {
|
|
ok = true;
|
|
break;
|
|
}
|
|
} catch {}
|
|
}
|
|
if (!ok) result.push(word);
|
|
}
|
|
return result;
|
|
};
|
|
const suggestWord = (session: Session, word: string): Array<string> => {
|
|
const loaded = sessionLoaded.get(session);
|
|
if (!loaded || loaded.size === 0) return [];
|
|
const out: Array<string> = [];
|
|
const seen = new Set<string>();
|
|
for (const entry of loaded.values()) {
|
|
try {
|
|
for (const s of entry.hunspell.suggest(word)) {
|
|
if (!seen.has(s)) {
|
|
seen.add(s);
|
|
out.push(s);
|
|
}
|
|
}
|
|
} catch {}
|
|
if (out.length >= 12) break;
|
|
}
|
|
return out.slice(0, 12);
|
|
};
|
|
const ensureSharedIpc = () => {
|
|
if (ipcRegistered) return;
|
|
ipcRegistered = true;
|
|
ipcMain.on(
|
|
'spellcheck-context-target',
|
|
(
|
|
event,
|
|
payload: {
|
|
isTextarea?: boolean;
|
|
},
|
|
) => {
|
|
contextSourceByWebContents.set(event.sender, {
|
|
isTextarea: Boolean(payload?.isTextarea),
|
|
ts: Date.now(),
|
|
});
|
|
},
|
|
);
|
|
ipcMain.on(
|
|
'spellcheck:update-autodetect-text',
|
|
(
|
|
event,
|
|
payload: {
|
|
contextId?: string;
|
|
text?: string;
|
|
},
|
|
) => {
|
|
const session = event.sender.session;
|
|
const state = sessionState.get(session) ?? {...defaultState};
|
|
if (!state.enabled || !state.autoDetect) return;
|
|
const text = typeof payload?.text === 'string' ? payload.text : '';
|
|
const contextKey = getAutodetectContextKey(payload?.contextId);
|
|
const nextLanguage = detectLanguageFromContextText(event.sender, contextKey, text);
|
|
const currentLanguage = sessionAutoDetectedLanguage.get(session) ?? null;
|
|
if (currentLanguage === nextLanguage) return;
|
|
sessionAutoDetectedLanguage.set(session, nextLanguage);
|
|
void applyStateToWebContents(event.sender, state, {broadcastResolved: true});
|
|
},
|
|
);
|
|
ipcMain.handle('spellcheck:get-bundled-dictionaries', (): Array<SpellcheckBundledDictionary> => listBundled());
|
|
ipcMain.handle('spellcheck:check-words', (event, words: Array<string>): Array<string> => {
|
|
if (!Array.isArray(words)) return [];
|
|
return checkWords(event.sender.session, words);
|
|
});
|
|
ipcMain.handle('spellcheck:suggest', (event, word: string): Array<string> => {
|
|
if (typeof word !== 'string' || word.length === 0) return [];
|
|
return suggestWord(event.sender.session, word);
|
|
});
|
|
};
|
|
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 => {
|
|
ensureSharedIpc();
|
|
if (!launchModeLogged) {
|
|
launchModeLogged = true;
|
|
const launchMode = getSpellcheckLaunchMode(process.argv);
|
|
if (launchMode !== 'default') {
|
|
log.warn('[Spellcheck] Launch diagnostic override active', {mode: launchMode});
|
|
}
|
|
}
|
|
const session = webContents.session;
|
|
let state: SpellcheckState = applyLaunchSpellcheckMode(sessionState.get(session) ?? {...defaultState});
|
|
sessionState.set(session, state);
|
|
const applyAndBroadcast = async (broadcast: boolean) => {
|
|
await applyStateToWebContents(webContents, state, {
|
|
broadcastState: broadcast,
|
|
broadcastResolved: broadcast,
|
|
});
|
|
};
|
|
const setState = async (
|
|
patch: RendererSpellcheckPatch | SpellcheckState,
|
|
opts?: {
|
|
broadcast?: boolean;
|
|
},
|
|
): Promise<SpellcheckState> => {
|
|
state = applyLaunchSpellcheckMode(normalizeState(state, patch));
|
|
sessionState.set(session, state);
|
|
await applyAndBroadcast(opts?.broadcast !== false);
|
|
return state;
|
|
};
|
|
void applyAndBroadcast(false);
|
|
webContents.on('did-finish-load', () => {
|
|
void applyStateToWebContents(webContents, sessionState.get(session) ?? state, {broadcastResolved: true});
|
|
});
|
|
if (!ipcMain.eventNames().includes('spellcheck-get-state')) {
|
|
ipcMain.handle('spellcheck-get-state', (event) => {
|
|
const targetSession = event.sender.session;
|
|
const next = applyLaunchSpellcheckMode(sessionState.get(targetSession) ?? {...defaultState});
|
|
sessionState.set(targetSession, next);
|
|
return next;
|
|
});
|
|
ipcMain.handle('spellcheck-set-state', async (event, patch: RendererSpellcheckPatch) => {
|
|
const targetSession = event.sender.session;
|
|
const current = sessionState.get(targetSession) ?? {...defaultState};
|
|
const next = applyLaunchSpellcheckMode(normalizeState(current, patch));
|
|
sessionState.set(targetSession, next);
|
|
await applyStateToWebContents(event.sender, next, {broadcastState: true, broadcastResolved: true});
|
|
return next;
|
|
});
|
|
ipcMain.handle('spellcheck-get-available-languages', (event) =>
|
|
listAvailableSpellcheckLanguages(event.sender.session),
|
|
);
|
|
ipcMain.handle('spellcheck-replace-misspelling', (event, replacement: string) => {
|
|
event.sender.replaceMisspelling(replacement);
|
|
});
|
|
ipcMain.handle('spellcheck-add-word-to-dictionary', async (event, word: string) => {
|
|
if (typeof word !== 'string' || word.length === 0) return;
|
|
const targetSession = event.sender.session;
|
|
const current = sessionState.get(targetSession) ?? {...defaultState};
|
|
const next = applyLaunchSpellcheckMode(
|
|
normalizeState(current, {
|
|
personalDictionary: [...current.personalDictionary, word],
|
|
}),
|
|
);
|
|
sessionState.set(targetSession, next);
|
|
if (useChromiumSpellchecker) {
|
|
try {
|
|
targetSession.addWordToSpellCheckerDictionary(word);
|
|
} catch {}
|
|
}
|
|
await applyStateToWebContents(event.sender, next, {broadcastState: true});
|
|
});
|
|
}
|
|
webContents.on('context-menu', (event, params) => {
|
|
if (!shouldHandleContextMenu(webContents, params)) return;
|
|
event.preventDefault();
|
|
const cur = sessionState.get(session) ?? {...defaultState};
|
|
const resolved = sessionResolvedEngine.get(session) ?? resolveEngine(cur, session);
|
|
const misspelledWord = params['misspelledWord'] as string | undefined;
|
|
let suggestions: Array<string> = [];
|
|
if (cur.enabled && misspelledWord) {
|
|
if (resolved.mode === 'hunspell') {
|
|
suggestions = suggestWord(session, misspelledWord);
|
|
} else {
|
|
suggestions = (params['dictionarySuggestions'] as Array<string> | undefined) ?? [];
|
|
}
|
|
}
|
|
webContents.send('textarea-context-menu', {
|
|
misspelledWord: cur.enabled ? misspelledWord : undefined,
|
|
suggestions: cur.enabled && 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'],
|
|
});
|
|
});
|
|
(
|
|
webContents as WebContents & {
|
|
__fluxerSpellcheckSetState?: typeof setState;
|
|
}
|
|
).__fluxerSpellcheckSetState = setState;
|
|
};
|