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')}
${legacyMpvPluginPaths.map((pluginPath) => `- ${escapeHtml(pluginPath)}
`).join('')}
`
: '';
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));
};
}