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' | 'open-yomitan-settings' | 'refresh' | 'skip-plugin' | 'finish'; export interface FirstRunSetupHtmlModel { configReady: boolean; dictionaryCount: number; canFinish: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; 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'; 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
${model.dictionaryCount} installed
${renderStatusBadge( model.dictionaryCount >= 1 ? 'Ready' : 'Missing', model.dictionaryCount >= 1 ? 'ready' : 'warn', )}
${model.message ? escapeHtml(model.message) : ''}
`; } export function parseFirstRunSetupSubmissionUrl( rawUrl: string, ): { action: FirstRunSetupAction } | 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 !== 'open-yomitan-settings' && action !== 'refresh' && action !== 'skip-plugin' && action !== 'finish' ) { return null; } 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) => { action: FirstRunSetupAction } | null; handleAction: (action: FirstRunSetupAction) => 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.action).catch((error) => { deps.logError('Failed handling first-run setup action', error); }); return true; }; } export function createOpenFirstRunSetupWindowHandler(deps: { maybeFocusExistingSetupWindow: () => boolean; createSetupWindow: () => TWindow; getSetupSnapshot: () => Promise; buildSetupHtml: (model: FirstRunSetupHtmlModel) => string; parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null; handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>; markSetupInProgress: () => Promise; markSetupCancelled: () => Promise; isSetupCompleted: () => boolean; 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 (action) => { const result = await deps.handleAction(action); 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', () => { if (!deps.isSetupCompleted()) { void deps.markSetupCancelled().catch((error) => { deps.logError('Failed marking first-run setup cancelled', error); }); } deps.clearSetupWindow(); }); void deps .markSetupInProgress() .then(() => render()) .catch((error) => deps.logError('Failed opening first-run setup window', error)); }; }