feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

31
src/core/utils/coerce.ts Normal file
View File

@@ -0,0 +1,31 @@
export function asFiniteNumber(
value: unknown,
fallback: number,
min?: number,
max?: number,
): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}
export function asString(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
export function asBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
return false;
}
}
return fallback;
}

View File

@@ -0,0 +1,81 @@
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { CliArgs } from '../../cli/args';
import { createLogger } from '../../logger';
const logger = createLogger('core:config-gen');
function formatBackupTimestamp(date = new Date()): string {
const pad = (v: number): string => String(v).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(question, (answer) => {
rl.close();
const normalized = answer.trim().toLowerCase();
resolve(normalized === 'y' || normalized === 'yes');
});
});
}
export async function generateDefaultConfigFile(
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
): Promise<number> {
const targetPath = args.configPath
? path.resolve(args.configPath)
: path.join(options.configDir, 'config.jsonc');
const template = options.generateTemplate(options.defaultConfig);
if (fs.existsSync(targetPath)) {
if (args.backupOverwrite) {
const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`;
fs.copyFileSync(targetPath, backupPath);
fs.writeFileSync(targetPath, template, 'utf-8');
logger.info(`Backed up existing config to ${backupPath}`);
logger.info(`Generated config at ${targetPath}`);
return 0;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
logger.error(
`Config exists at ${targetPath}. Re-run with --backup-overwrite to back up and overwrite.`,
);
return 1;
}
const confirmed = await promptYesNo(
`Config exists at ${targetPath}. Back up and overwrite? [y/N] `,
);
if (!confirmed) {
logger.info('Config generation cancelled.');
return 0;
}
const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`;
fs.copyFileSync(targetPath, backupPath);
fs.writeFileSync(targetPath, template, 'utf-8');
logger.info(`Backed up existing config to ${backupPath}`);
logger.info(`Generated config at ${targetPath}`);
return 0;
}
const parentDir = path.dirname(targetPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
fs.writeFileSync(targetPath, template, 'utf-8');
logger.info(`Generated config at ${targetPath}`);
return 0;
}

View File

@@ -0,0 +1,40 @@
import { CliArgs, shouldStartApp } from '../../cli/args';
import { createLogger } from '../../logger';
const logger = createLogger('core:electron-backend');
function getElectronOzonePlatformHint(): string | null {
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
if (hint) return hint;
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
if (ozone) return ozone;
return null;
}
function shouldPreferWaylandBackend(): boolean {
return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK);
}
export function forceX11Backend(args: CliArgs): void {
if (process.platform !== 'linux') return;
if (!shouldStartApp(args)) return;
if (shouldPreferWaylandBackend()) return;
const hint = getElectronOzonePlatformHint();
if (hint === 'x11') return;
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
process.env.OZONE_PLATFORM = 'x11';
}
export function enforceUnsupportedWaylandMode(args: CliArgs): void {
if (process.platform !== 'linux') return;
if (!shouldStartApp(args)) return;
const hint = getElectronOzonePlatformHint();
if (hint !== 'wayland') return;
const message =
'Unsupported Electron backend: Wayland. Set ELECTRON_OZONE_PLATFORM_HINT=x11 and restart SubMiner.';
logger.error(message);
throw new Error(message);
}

5
src/core/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { generateDefaultConfigFile } from './config-gen';
export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend';
export { resolveKeybindings } from './keybindings';
export { resolveConfiguredShortcuts } from './shortcut-config';
export { showDesktopNotification } from './notification';

View File

@@ -0,0 +1,27 @@
import { Config, Keybinding } from '../../types';
export function resolveKeybindings(config: Config, defaultKeybindings: Keybinding[]): Keybinding[] {
const userBindings = config.keybindings || [];
const bindingMap = new Map<string, (string | number)[] | null>();
for (const binding of defaultKeybindings) {
bindingMap.set(binding.key, binding.command);
}
for (const binding of userBindings) {
if (binding.command === null) {
bindingMap.delete(binding.key);
} else {
bindingMap.set(binding.key, binding.command);
}
}
const keybindings: Keybinding[] = [];
for (const [key, command] of bindingMap) {
if (command !== null) {
keybindings.push({ key, command });
}
}
return keybindings;
}

View File

@@ -0,0 +1,53 @@
import { Notification, nativeImage } from 'electron';
import * as fs from 'fs';
import { createLogger } from '../../logger';
const logger = createLogger('core:notification');
export function showDesktopNotification(
title: string,
options: { body?: string; icon?: string },
): void {
const notificationOptions: {
title: string;
body?: string;
icon?: Electron.NativeImage | string;
} = { title };
if (options.body) {
notificationOptions.body = options.body;
}
if (options.icon) {
const isFilePath =
typeof options.icon === 'string' &&
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
if (isFilePath) {
if (fs.existsSync(options.icon)) {
notificationOptions.icon = options.icon;
} else {
logger.warn('Notification icon file not found', options.icon);
}
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) {
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, '');
try {
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
if (image.isEmpty()) {
logger.warn(
'Notification icon created from base64 is empty - image format may not be supported by Electron',
);
} else {
notificationOptions.icon = image;
}
} catch (err) {
logger.error('Failed to create notification icon from base64', err);
}
} else {
notificationOptions.icon = options.icon;
}
}
const notification = new Notification(notificationOptions);
notification.show();
}

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Config } from '../../types';
import { resolveConfiguredShortcuts } from './shortcut-config';
test('forces Anki-dependent shortcuts to null when AnkiConnect is explicitly disabled', () => {
const config: Config = {
ankiConnect: { enabled: false },
shortcuts: {
copySubtitle: 'Ctrl+KeyC',
updateLastCardFromClipboard: 'Ctrl+KeyU',
triggerFieldGrouping: 'Alt+KeyG',
mineSentence: 'Ctrl+Digit1',
mineSentenceMultiple: 'Ctrl+Digit2',
markAudioCard: 'Alt+KeyM',
},
};
const defaults: Config = {
shortcuts: {
updateLastCardFromClipboard: 'Alt+KeyL',
triggerFieldGrouping: 'Alt+KeyF',
mineSentence: 'KeyQ',
mineSentenceMultiple: 'KeyW',
markAudioCard: 'KeyE',
},
};
const resolved = resolveConfiguredShortcuts(config, defaults);
assert.equal(resolved.updateLastCardFromClipboard, null);
assert.equal(resolved.triggerFieldGrouping, null);
assert.equal(resolved.mineSentence, null);
assert.equal(resolved.mineSentenceMultiple, null);
assert.equal(resolved.markAudioCard, null);
assert.equal(resolved.copySubtitle, 'Ctrl+C');
});
test('keeps Anki-dependent shortcuts enabled and normalized when AnkiConnect is enabled', () => {
const config: Config = {
ankiConnect: { enabled: true },
shortcuts: {
updateLastCardFromClipboard: 'Ctrl+KeyU',
mineSentence: 'Ctrl+Digit1',
mineSentenceMultiple: 'Ctrl+Digit2',
},
};
const defaults: Config = {
shortcuts: {
triggerFieldGrouping: 'Alt+KeyG',
markAudioCard: 'Alt+KeyM',
},
};
const resolved = resolveConfiguredShortcuts(config, defaults);
assert.equal(resolved.updateLastCardFromClipboard, 'Ctrl+U');
assert.equal(resolved.triggerFieldGrouping, 'Alt+G');
assert.equal(resolved.mineSentence, 'Ctrl+1');
assert.equal(resolved.mineSentenceMultiple, 'Ctrl+2');
assert.equal(resolved.markAudioCard, 'Alt+M');
});
test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
const config: Config = {};
const defaults: Config = {
shortcuts: {
mineSentence: 'KeyQ',
openRuntimeOptions: 'Digit9',
},
};
const resolved = resolveConfiguredShortcuts(config, defaults);
assert.equal(resolved.mineSentence, 'Q');
assert.equal(resolved.openRuntimeOptions, '9');
});

View File

@@ -0,0 +1,87 @@
import { Config } from '../../types';
export interface ConfiguredShortcuts {
toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
copySubtitle: string | null | undefined;
copySubtitleMultiple: string | null | undefined;
updateLastCardFromClipboard: string | null | undefined;
triggerFieldGrouping: string | null | undefined;
triggerSubsync: string | null | undefined;
mineSentence: string | null | undefined;
mineSentenceMultiple: string | null | undefined;
multiCopyTimeoutMs: number;
toggleSecondarySub: string | null | undefined;
markAudioCard: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
}
export function resolveConfiguredShortcuts(
config: Config,
defaultConfig: Config,
): ConfiguredShortcuts {
const isAnkiConnectDisabled = config.ankiConnect?.enabled === false;
const normalizeShortcut = (value: string | null | undefined): string | null | undefined => {
if (typeof value !== 'string') return value;
return value.replace(/\bKey([A-Z])\b/g, '$1').replace(/\bDigit([0-9])\b/g, '$1');
};
return {
toggleVisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleVisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
),
toggleInvisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleInvisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal,
),
copySubtitle: normalizeShortcut(
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
),
copySubtitleMultiple: normalizeShortcut(
config.shortcuts?.copySubtitleMultiple ?? defaultConfig.shortcuts?.copySubtitleMultiple,
),
updateLastCardFromClipboard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.updateLastCardFromClipboard ??
defaultConfig.shortcuts?.updateLastCardFromClipboard),
),
triggerFieldGrouping: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.triggerFieldGrouping ?? defaultConfig.shortcuts?.triggerFieldGrouping),
),
triggerSubsync: normalizeShortcut(
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync,
),
mineSentence: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence),
),
mineSentenceMultiple: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentenceMultiple ?? defaultConfig.shortcuts?.mineSentenceMultiple),
),
multiCopyTimeoutMs:
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
toggleSecondarySub: normalizeShortcut(
config.shortcuts?.toggleSecondarySub ?? defaultConfig.shortcuts?.toggleSecondarySub,
),
markAudioCard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
),
openRuntimeOptions: normalizeShortcut(
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
),
openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
),
};
}