type FocusableWindowLike = { focus: () => void; }; type FirstRunSetupWebContentsLike = { on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; }; type FirstRunSetupWindowLike = FocusableWindowLike & { webContents: FirstRunSetupWebContentsLike; loadURL: (url: string) => unknown; on: (event: 'closed', handler: () => void) => void; isDestroyed: () => boolean; close: () => void; }; export type FirstRunSetupAction = | 'install-plugin' | 'configure-windows-mpv-shortcuts' | 'open-yomitan-settings' | 'refresh' | 'skip-plugin' | 'finish'; export interface FirstRunSetupSubmission { action: FirstRunSetupAction; startMenuEnabled?: boolean; desktopEnabled?: boolean; } export interface FirstRunSetupHtmlModel { configReady: boolean; dictionaryCount: number; canFinish: boolean; externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: { supported: boolean; startMenuEnabled: boolean; desktopEnabled: boolean; startMenuInstalled: boolean; desktopInstalled: boolean; status: 'installed' | 'optional' | 'skipped' | 'failed'; }; message: string | null; } function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'danger'): string { return `${escapeHtml(value)}`; } export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { const pluginActionLabel = model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin'; const pluginLabel = model.pluginStatus === 'installed' ? 'Installed' : model.pluginStatus === 'skipped' ? 'Skipped' : model.pluginStatus === 'failed' ? 'Failed' : 'Optional'; const pluginTone = model.pluginStatus === 'installed' ? 'ready' : model.pluginStatus === 'failed' ? 'danger' : model.pluginStatus === 'skipped' ? 'muted' : 'warn'; const windowsShortcutLabel = model.windowsMpvShortcuts.status === 'installed' ? 'Installed' : model.windowsMpvShortcuts.status === 'skipped' ? 'Skipped' : model.windowsMpvShortcuts.status === 'failed' ? 'Failed' : 'Optional'; const windowsShortcutTone = model.windowsMpvShortcuts.status === 'installed' ? 'ready' : model.windowsMpvShortcuts.status === 'failed' ? 'danger' : model.windowsMpvShortcuts.status === 'skipped' ? 'muted' : 'warn'; const windowsShortcutCard = model.windowsMpvShortcuts.supported ? `
Windows mpv launcher
Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.
Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
` : ''; const yomitanMeta = model.externalYomitanConfigured ? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.' : `${model.dictionaryCount} installed`; const yomitanBadgeLabel = model.externalYomitanConfigured ? 'External' : model.dictionaryCount >= 1 ? 'Ready' : 'Missing'; const yomitanBadgeTone = model.externalYomitanConfigured ? 'ready' : model.dictionaryCount >= 1 ? 'ready' : 'warn'; const footerMessage = model.externalYomitanConfigured ? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.' : 'Finish stays locked until Yomitan reports at least one installed dictionary.'; return ` SubMiner First-Run Setup

SubMiner setup

Config file
Default config directory seeded automatically.
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
mpv plugin
${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}
${renderStatusBadge(pluginLabel, pluginTone)}
Yomitan dictionaries
${escapeHtml(yomitanMeta)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
${windowsShortcutCard}
${model.message ? escapeHtml(model.message) : ''}
`; } export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSubmission | null { if (!rawUrl.startsWith('subminer://first-run-setup')) { return null; } const parsed = new URL(rawUrl); const action = parsed.searchParams.get('action'); if ( action !== 'install-plugin' && action !== 'configure-windows-mpv-shortcuts' && action !== 'open-yomitan-settings' && action !== 'refresh' && action !== 'skip-plugin' && action !== 'finish' ) { return null; } if (action === 'configure-windows-mpv-shortcuts') { return { action, startMenuEnabled: parsed.searchParams.get('startMenu') === '1', desktopEnabled: parsed.searchParams.get('desktop') === '1', }; } return { action }; } export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: { getSetupWindow: () => FocusableWindowLike | null; }) { return (): boolean => { const window = deps.getSetupWindow(); if (!window) return false; window.focus(); return true; }; } export function createHandleFirstRunSetupNavigationHandler(deps: { parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null; handleAction: (submission: FirstRunSetupSubmission) => Promise; logError: (message: string, error: unknown) => void; }) { return (params: { url: string; preventDefault: () => void }): boolean => { const submission = deps.parseSubmissionUrl(params.url); if (!submission) return false; params.preventDefault(); void deps.handleAction(submission).catch((error) => { deps.logError('Failed handling first-run setup action', error); }); return true; }; } export function createOpenFirstRunSetupWindowHandler< TWindow extends FirstRunSetupWindowLike, >(deps: { maybeFocusExistingSetupWindow: () => boolean; createSetupWindow: () => TWindow; getSetupSnapshot: () => Promise; buildSetupHtml: (model: FirstRunSetupHtmlModel) => string; parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null; handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>; markSetupInProgress: () => Promise; markSetupCancelled: () => Promise; isSetupCompleted: () => boolean; shouldQuitWhenClosedIncomplete: () => boolean; quitApp: () => void; clearSetupWindow: () => void; setSetupWindow: (window: TWindow) => void; encodeURIComponent: (value: string) => string; logError: (message: string, error: unknown) => void; }) { return (): void => { if (deps.maybeFocusExistingSetupWindow()) { return; } const setupWindow = deps.createSetupWindow(); deps.setSetupWindow(setupWindow); const render = async (): Promise => { const model = await deps.getSetupSnapshot(); const html = deps.buildSetupHtml(model); await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`); }; const handleNavigation = createHandleFirstRunSetupNavigationHandler({ parseSubmissionUrl: deps.parseSubmissionUrl, handleAction: async (submission) => { const result = await deps.handleAction(submission); if (result?.closeWindow) { if (!setupWindow.isDestroyed()) { setupWindow.close(); } return; } if (!setupWindow.isDestroyed()) { await render(); } }, logError: deps.logError, }); setupWindow.webContents.on('will-navigate', (event, url) => { handleNavigation({ url, preventDefault: () => { if (event && typeof event === 'object' && 'preventDefault' in event) { (event as { preventDefault?: () => void }).preventDefault?.(); } }, }); }); setupWindow.on('closed', () => { const setupCompleted = deps.isSetupCompleted(); if (!setupCompleted) { void deps.markSetupCancelled().catch((error) => { deps.logError('Failed marking first-run setup cancelled', error); }); } deps.clearSetupWindow(); if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) { deps.quitApp(); } }); void deps .markSetupInProgress() .then(() => render()) .catch((error) => deps.logError('Failed opening first-run setup window', error)); }; }