import { getFirstRunSetupCompletionMessage } from './first-run-setup-service'; import type { BunSnapshot, CommandLineLauncherSnapshot, LauncherSnapshot, } from './command-line-launcher'; 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 = | 'configure-mpv-executable-path' | 'remove-legacy-plugin' | 'configure-windows-mpv-shortcuts' | 'install-bun' | 'install-command-line-launcher' | 'open-yomitan-settings' | 'refresh' | 'finish'; export interface FirstRunSetupSubmission { action: FirstRunSetupAction; mpvExecutablePath?: string; startMenuEnabled?: boolean; desktopEnabled?: boolean; } export interface FirstRunSetupHtmlModel { configReady: boolean; dictionaryCount: number; canFinish: boolean; externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; legacyMpvPluginPaths?: string[]; mpvExecutablePath: string; mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid'; windowsMpvShortcuts: { supported: boolean; startMenuEnabled: boolean; desktopEnabled: boolean; startMenuInstalled: boolean; desktopInstalled: boolean; status: 'installed' | 'optional' | 'skipped' | 'failed'; }; commandLineLauncher: CommandLineLauncherSnapshot; 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)}`; } function formatCommand(command: string[] | null): string { return command?.join(' ') ?? 'No install command detected'; } function getBunStatusLabel(status: BunSnapshot['status']): string { switch (status) { case 'ready': return 'Ready'; case 'installing': return 'Installing'; case 'failed': return 'Failed'; case 'missing': return 'Missing'; } } function getLauncherStatusLabel(status: LauncherSnapshot['status']): string { switch (status) { case 'ready': return 'Ready'; case 'installed_bun_missing': return 'Installed, Bun missing'; case 'not_installed': return 'Not installed'; case 'not_on_path': return 'Not on PATH'; case 'shadowed': return 'Shadowed'; case 'not_installable': return 'Not installable'; case 'failed': return 'Failed'; } } function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' { if (status === 'ready') return 'ready'; if (status === 'failed') return 'danger'; if (status === 'installing') return 'muted'; return 'warn'; } function getLauncherTone( status: LauncherSnapshot['status'], ): 'ready' | 'warn' | 'muted' | 'danger' { if (status === 'ready') return 'ready'; if (status === 'failed') return 'danger'; if (status === 'installed_bun_missing' || status === 'not_installed') return 'warn'; return 'muted'; } function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string { if (!commandLineLauncher.supported) { return ''; } const bun = commandLineLauncher.bun; const launcher = commandLineLauncher.launcher; const bunMeta = bun.status === 'ready' ? [ bun.commandPath ? `Path: ${bun.commandPath}` : null, bun.version ? `Version: ${bun.version}` : null, ].filter(Boolean) : [ bun.installMethod ? `Method: ${bun.installMethod}` : null, `Command: ${formatCommand(bun.installCommand)}`, bun.message, ].filter(Boolean); const launcherMeta = [ launcher.commandPath ? `Command: ${launcher.commandPath}` : null, launcher.installPath ? `Install target: ${launcher.installPath}` : null, launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null, launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null, launcher.message, bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null, ].filter(Boolean); const bunInstallButton = bun.status === 'missing' || bun.status === 'failed' ? `` : ''; const launcherButtonDisabled = launcher.status === 'failed' ? '' : ''; return `

Command line launcher

Optional. Setup can finish without Bun or the launcher.
Bun runtime ${bunMeta.map((line) => `
${escapeHtml(String(line))}
`).join('')}
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
${bunInstallButton}
SubMiner launcher ${launcherMeta.map((line) => `
${escapeHtml(String(line))}
`).join('')}
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
`; } export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? []; const finishButtonLabel = legacyMpvPluginPaths.length > 0 && model.canFinish ? 'Continue without removing' : 'Finish setup'; const pluginLabel = legacyMpvPluginPaths.length > 0 ? 'Legacy detected' : model.pluginStatus === 'failed' ? 'Failed' : 'Ready'; const pluginTone = legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready'; 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 mpvExecutablePathLabel = model.mpvExecutablePathStatus === 'configured' ? 'Configured' : model.mpvExecutablePathStatus === 'invalid' ? 'Invalid' : 'Blank'; const mpvExecutablePathTone = model.mpvExecutablePathStatus === 'configured' ? 'ready' : model.mpvExecutablePathStatus === 'invalid' ? 'danger' : 'muted'; const mpvExecutablePathCurrent = model.mpvExecutablePathStatus === 'blank' ? 'blank (PATH discovery)' : model.mpvExecutablePathStatus === 'invalid' ? `${model.mpvExecutablePath} (invalid; file not found)` : model.mpvExecutablePath; const mpvExecutablePathCard = model.windowsMpvShortcuts.supported ? `
mpv executable path
Leave blank to auto-discover mpv.exe from PATH.
Current: ${escapeHtml(mpvExecutablePathCurrent)}
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
` : ''; 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 legacyPluginCard = legacyMpvPluginPaths.length > 0 ? `
Legacy mpv plugin
Regular mpv still loads SubMiner from these mpv scripts paths.
${renderStatusBadge('Found', 'warn')}
` : ''; 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 blockerMessage = getFirstRunSetupCompletionMessage(model); const footerMessage = blockerMessage ? blockerMessage : model.canFinish ? 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 unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.' : '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 runtime plugin
${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}
Managed mpv launches use the bundled runtime plugin.
${renderStatusBadge(pluginLabel, pluginTone)}
Yomitan dictionaries
${escapeHtml(yomitanMeta)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
${mpvExecutablePathCard} ${windowsShortcutCard} ${renderCommandLineLauncherSection(model.commandLineLauncher)} ${legacyPluginCard}
${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 !== 'configure-mpv-executable-path' && action !== 'remove-legacy-plugin' && action !== 'configure-windows-mpv-shortcuts' && action !== 'install-bun' && action !== 'install-command-line-launcher' && action !== 'open-yomitan-settings' && action !== 'refresh' && action !== 'finish' ) { return null; } if (action === 'configure-mpv-executable-path') { return { action, mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '', }; } 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 => { if (!params.url.startsWith('subminer://first-run-setup')) { params.preventDefault(); return true; } params.preventDefault(); let submission: FirstRunSetupSubmission | null; try { submission = deps.parseSubmissionUrl(params.url); } catch { return true; } if (!submission) return true; 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)); }; }