import fs from 'node:fs'; import { createDefaultSetupState, getDefaultConfigFilePaths, getSetupStatePath, isSetupCompleted, readSetupState, writeSetupState, type SetupPluginInstallStatus, type SetupWindowsMpvShortcutInstallStatus, type SetupState, } from '../../shared/setup-state'; import type { CliArgs } from '../../cli/args'; import type { InstalledFirstRunPluginCandidate, LegacyMpvPluginRemovalResult, } from './first-run-setup-plugin'; import type { CommandLineLauncherSnapshot } from './command-line-launcher'; export interface SetupWindowsMpvShortcutSnapshot { supported: boolean; startMenuEnabled: boolean; desktopEnabled: boolean; startMenuInstalled: boolean; desktopInstalled: boolean; status: 'installed' | 'optional' | 'skipped' | 'failed'; message: string | null; } export interface SetupStatusSnapshot { configReady: boolean; dictionaryCount: number; canFinish: boolean; externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; legacyMpvPluginPaths: string[]; windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot; commandLineLauncher: CommandLineLauncherSnapshot; message: string | null; state: SetupState; } export interface PluginInstallResult { ok: boolean; pluginInstallStatus: SetupPluginInstallStatus; pluginInstallPathSummary: string | null; message: string; } export interface FirstRunSetupService { ensureSetupStateInitialized: () => Promise; getSetupStatus: () => Promise; refreshStatus: (message?: string | null) => Promise; markSetupInProgress: () => Promise; markSetupCancelled: () => Promise; markSetupCompleted: () => Promise; removeLegacyMpvPlugin: () => Promise; configureWindowsMpvShortcuts: (preferences: { startMenuEnabled: boolean; desktopEnabled: boolean; }) => Promise; installBun: () => Promise; installCommandLineLauncher: () => Promise; isSetupCompleted: () => boolean; } function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { return Boolean( args.toggle || args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.launchMpv || args.settings || args.configSettings || args.show || args.hide || args.showVisibleOverlay || args.hideVisibleOverlay || args.copySubtitle || args.copySubtitleMultiple || args.copySubtitleCount !== undefined || args.mineSentence || args.mineSentenceMultiple || args.mineSentenceCount !== undefined || args.updateLastCardFromClipboard || args.refreshKnownWords || args.toggleSecondarySub || args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || args.toggleStatsOverlay || args.markWatched || args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || args.openControllerSelect || args.openControllerDebug || args.openJimaku || args.openYoutubePicker || args.openPlaylistBrowser || args.replayCurrentSubtitle || args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || args.cycleRuntimeOptionId !== undefined || args.anilistStatus || args.anilistLogout || args.anilistSetup || args.anilistRetryQueue || args.dictionary || args.stats || args.jellyfin || args.jellyfinLogin || args.jellyfinLogout || args.jellyfinLibraries || args.jellyfinItems || args.jellyfinSubtitles || args.jellyfinSubtitleUrlsOnly || args.jellyfinPlay || args.jellyfinRemoteAnnounce || args.jellyfinPreviewAuth || args.texthooker || args.update || args.help, ); } export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean { if (args.setup) return true; if (!args.start && !args.background) return false; return !hasAnyStartupCommandBeyondSetup(args); } export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean { return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args); } function getPluginStatus( state: SetupState, pluginInstalled: boolean, ): SetupStatusSnapshot['pluginStatus'] { if (pluginInstalled) return 'installed'; if (state.pluginInstallStatus === 'failed') return 'failed'; return 'required'; } function getWindowsMpvShortcutStatus( state: SetupState, installed: { startMenuInstalled: boolean; desktopInstalled: boolean }, ): SetupWindowsMpvShortcutSnapshot['status'] { if (installed.startMenuInstalled || installed.desktopInstalled) return 'installed'; if (state.windowsMpvShortcutLastStatus === 'skipped') return 'skipped'; if (state.windowsMpvShortcutLastStatus === 'failed') return 'failed'; return 'optional'; } function getEffectiveWindowsMpvShortcutPreferences( state: SetupState, installed: { startMenuInstalled: boolean; desktopInstalled: boolean }, ): { startMenuEnabled: boolean; desktopEnabled: boolean } { if (state.windowsMpvShortcutLastStatus === 'unknown') { return { startMenuEnabled: installed.startMenuInstalled, desktopEnabled: installed.desktopInstalled, }; } return { startMenuEnabled: state.windowsMpvShortcutPreferences.startMenuEnabled, desktopEnabled: state.windowsMpvShortcutPreferences.desktopEnabled, }; } function isYomitanSetupSatisfied(options: { configReady: boolean; dictionaryCount: number; externalYomitanConfigured: boolean; }): boolean { if (!options.configReady) { return false; } return options.externalYomitanConfigured || options.dictionaryCount >= 1; } function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnapshot { return { supported: false, bun: { status: 'missing', commandPath: null, version: null, installMethod: null, installCommand: null, message: 'Command-line launcher setup is unavailable in this runtime.', }, launcher: { status: 'not_installable', commandPath: null, installPath: null, pathDir: null, shadowedBy: null, message: 'Command-line launcher setup is unavailable in this runtime.', }, }; } export function getFirstRunSetupCompletionMessage(snapshot: { configReady: boolean; dictionaryCount: number; externalYomitanConfigured: boolean; pluginStatus: SetupStatusSnapshot['pluginStatus']; }): string | null { if (!snapshot.configReady) { return 'Create or provide the config file before finishing setup.'; } if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) { return 'Install at least one Yomitan dictionary before finishing setup.'; } return null; } async function resolveYomitanSetupStatus(deps: { configFilePaths: { jsoncPath: string; jsonPath: string }; getYomitanDictionaryCount: () => Promise; isExternalYomitanConfigured?: () => boolean; }): Promise<{ configReady: boolean; dictionaryCount: number; externalYomitanConfigured: boolean; }> { const configReady = fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath); const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false; if (configReady && externalYomitanConfigured) { return { configReady, dictionaryCount: 0, externalYomitanConfigured, }; } return { configReady, dictionaryCount: await deps.getYomitanDictionaryCount(), externalYomitanConfigured, }; } export function createFirstRunSetupService(deps: { platform?: NodeJS.Platform; configDir: string; getYomitanDictionaryCount: () => Promise; isExternalYomitanConfigured?: () => boolean; detectPluginInstalled: () => boolean | Promise; detectLegacyMpvPluginCandidates?: () => | InstalledFirstRunPluginCandidate[] | Promise; installPlugin?: () => Promise; removeLegacyMpvPlugins?: ( candidates: InstalledFirstRunPluginCandidate[], ) => Promise; detectWindowsMpvShortcuts?: () => | { startMenuInstalled: boolean; desktopInstalled: boolean } | Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>; applyWindowsMpvShortcuts?: (preferences: { startMenuEnabled: boolean; desktopEnabled: boolean; }) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>; detectCommandLineLauncher?: () => | CommandLineLauncherSnapshot | Promise; installBun?: () => Promise<{ ok: boolean; message: string }>; installCommandLineLauncher?: () => Promise<{ ok: boolean; installPath: string | null; message: string; }>; onStateChanged?: (state: SetupState) => void; }): FirstRunSetupService { const setupStatePath = getSetupStatePath(deps.configDir); const configFilePaths = getDefaultConfigFilePaths(deps.configDir); const isWindows = (deps.platform ?? process.platform) === 'win32'; let completed = false; const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState(); const writeState = (state: SetupState): SetupState => { writeSetupState(setupStatePath, state); completed = state.status === 'completed'; deps.onStateChanged?.(state); return state; }; const buildSnapshot = async (state: SetupState, message: string | null = null) => { const { configReady, dictionaryCount, externalYomitanConfigured } = await resolveYomitanSetupStatus({ configFilePaths, getYomitanDictionaryCount: deps.getYomitanDictionaryCount, isExternalYomitanConfigured: deps.isExternalYomitanConfigured, }); const pluginInstalled = await deps.detectPluginInstalled(); const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? []; const detectedWindowsMpvShortcuts = isWindows ? await deps.detectWindowsMpvShortcuts?.() : undefined; const commandLineLauncher = (await deps.detectCommandLineLauncher?.()) ?? createUnsupportedCommandLineLauncherSnapshot(); const installedWindowsMpvShortcuts = { startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false, desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false, }; const effectiveWindowsMpvShortcutPreferences = getEffectiveWindowsMpvShortcutPreferences( state, installedWindowsMpvShortcuts, ); return { configReady, dictionaryCount, canFinish: isYomitanSetupSatisfied({ configReady, dictionaryCount, externalYomitanConfigured, }), externalYomitanConfigured, pluginStatus: getPluginStatus(state, pluginInstalled), pluginInstallPathSummary: state.pluginInstallPathSummary, legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path), windowsMpvShortcuts: { supported: isWindows, startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled, desktopEnabled: effectiveWindowsMpvShortcutPreferences.desktopEnabled, startMenuInstalled: installedWindowsMpvShortcuts.startMenuInstalled, desktopInstalled: installedWindowsMpvShortcuts.desktopInstalled, status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts), message: null, }, commandLineLauncher, message, state, } satisfies SetupStatusSnapshot; }; const refreshWithState = async (state: SetupState, message: string | null = null) => { const snapshot = await buildSnapshot(state, message); if (snapshot.state.lastSeenYomitanDictionaryCount !== snapshot.dictionaryCount) { snapshot.state = writeState({ ...snapshot.state, lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, }); } return snapshot; }; return { ensureSetupStateInitialized: async () => { const state = readState(); const { configReady, dictionaryCount, externalYomitanConfigured } = await resolveYomitanSetupStatus({ configFilePaths, getYomitanDictionaryCount: deps.getYomitanDictionaryCount, isExternalYomitanConfigured: deps.isExternalYomitanConfigured, }); const canFinish = isYomitanSetupSatisfied({ configReady, dictionaryCount, externalYomitanConfigured, }); if (isSetupCompleted(state) && canFinish) { completed = true; return refreshWithState(state); } if (canFinish) { const completedState = writeState({ ...state, status: 'completed', completedAt: new Date().toISOString(), completionSource: 'legacy_auto_detected', yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: dictionaryCount, }); return buildSnapshot(completedState); } return refreshWithState( writeState({ ...state, status: state.status === 'cancelled' ? 'cancelled' : 'incomplete', completedAt: null, completionSource: null, yomitanSetupMode: null, lastSeenYomitanDictionaryCount: dictionaryCount, }), ); }, getSetupStatus: async () => refreshWithState(readState()), refreshStatus: async (message = null) => refreshWithState(readState(), message), markSetupInProgress: async () => { const state = readState(); if (state.status === 'completed') { const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? []; if (legacyMpvPluginCandidates.length === 0) { completed = true; return refreshWithState(state); } completed = false; return refreshWithState( writeState({ ...state, status: 'in_progress', completedAt: null, completionSource: null, }), ); } return refreshWithState(writeState({ ...state, status: 'in_progress' })); }, markSetupCancelled: async () => { const state = readState(); if (state.status === 'completed') { completed = true; return refreshWithState(state); } return refreshWithState(writeState({ ...state, status: 'cancelled' })); }, markSetupCompleted: async () => { const state = readState(); const snapshot = await buildSnapshot(state); if (!snapshot.canFinish) { return snapshot; } return refreshWithState( writeState({ ...state, status: 'completed', completedAt: new Date().toISOString(), completionSource: 'user', yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, }), ); }, removeLegacyMpvPlugin: async () => { const candidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? []; if (candidates.length === 0) { return refreshWithState(readState(), 'No legacy mpv plugin files were found.'); } if (!deps.removeLegacyMpvPlugins) { return refreshWithState( readState(), 'Legacy mpv plugin removal is unavailable in this runtime.', ); } const result = await deps.removeLegacyMpvPlugins(candidates); if (result.ok) { return refreshWithState( readState(), 'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.', ); } const removedCount = result.removedPaths.length; const removedText = `${removedCount} legacy mpv plugin path${removedCount === 1 ? '' : 's'}`; const failedText = result.failedPaths .map((failure) => `${failure.path} (${failure.message})`) .join(', '); return refreshWithState( readState(), `Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`, ); }, configureWindowsMpvShortcuts: async (preferences) => { if (!isWindows || !deps.applyWindowsMpvShortcuts) { return refreshWithState( writeState({ ...readState(), windowsMpvShortcutPreferences: { startMenuEnabled: preferences.startMenuEnabled, desktopEnabled: preferences.desktopEnabled, }, }), null, ); } const result = await deps.applyWindowsMpvShortcuts(preferences); const latestState = readState(); return refreshWithState( writeState({ ...latestState, windowsMpvShortcutPreferences: { startMenuEnabled: preferences.startMenuEnabled, desktopEnabled: preferences.desktopEnabled, }, windowsMpvShortcutLastStatus: result.status, }), result.message, ); }, installBun: async () => { if (!deps.installBun) { return refreshWithState(readState(), 'Bun installation is unavailable in this runtime.'); } const result = await deps.installBun(); return refreshWithState( writeState({ ...readState(), bunInstallStatus: result.ok ? 'installed' : 'failed', }), result.message, ); }, installCommandLineLauncher: async () => { if (!deps.installCommandLineLauncher) { return refreshWithState( readState(), 'Command-line launcher installation is unavailable in this runtime.', ); } const result = await deps.installCommandLineLauncher(); return refreshWithState( writeState({ ...readState(), launcherInstallStatus: result.ok ? 'installed' : 'failed', launcherInstallPath: result.ok ? result.installPath : null, }), result.message, ); }, isSetupCompleted: () => completed || isSetupCompleted(readState()), }; }