mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
31
src/core/utils/coerce.ts
Normal file
31
src/core/utils/coerce.ts
Normal 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;
|
||||
}
|
||||
81
src/core/utils/config-gen.ts
Normal file
81
src/core/utils/config-gen.ts
Normal 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;
|
||||
}
|
||||
40
src/core/utils/electron-backend.ts
Normal file
40
src/core/utils/electron-backend.ts
Normal 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
5
src/core/utils/index.ts
Normal 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';
|
||||
27
src/core/utils/keybindings.ts
Normal file
27
src/core/utils/keybindings.ts
Normal 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;
|
||||
}
|
||||
53
src/core/utils/notification.ts
Normal file
53
src/core/utils/notification.ts
Normal 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();
|
||||
}
|
||||
76
src/core/utils/shortcut-config.test.ts
Normal file
76
src/core/utils/shortcut-config.test.ts
Normal 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');
|
||||
});
|
||||
87
src/core/utils/shortcut-config.ts
Normal file
87
src/core/utils/shortcut-config.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user