From beb48ab0cb10053619d2f67bf85c2ddd8ead0862 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 12 Mar 2026 00:28:01 -0700 Subject: [PATCH] Allow first-run setup completion with external Yomitan profile - Treat `yomitan.externalProfilePath` as satisfying dictionary setup in launcher and app first-run flow - Reopen setup if an externally-completed setup later runs without external profile and no internal dictionaries - Bump setup state to v3 with `yomitanSetupMode` migration and update setup UI/docs/tests --- changes/external-yomitan-profile.md | 1 + docs-site/configuration.md | 1 + launcher/commands/playback-command.ts | 2 + launcher/config-domain-parsers.test.ts | 34 ++++ launcher/config.ts | 17 ++ launcher/setup-gate.test.ts | 34 +++- launcher/setup-gate.ts | 4 + src/core/services/app-ready.test.ts | 4 +- src/core/services/startup.ts | 2 + src/main.ts | 2 + .../runtime/first-run-setup-service.test.ts | 148 ++++++++++++++++++ src/main/runtime/first-run-setup-service.ts | 81 ++++++++-- .../runtime/first-run-setup-window.test.ts | 29 ++++ src/main/runtime/first-run-setup-window.ts | 27 +++- src/shared/setup-state.test.ts | 45 +++++- src/shared/setup-state.ts | 24 ++- 16 files changed, 427 insertions(+), 28 deletions(-) diff --git a/changes/external-yomitan-profile.md b/changes/external-yomitan-profile.md index d1c6971..7d916c0 100644 --- a/changes/external-yomitan-profile.md +++ b/changes/external-yomitan-profile.md @@ -4,3 +4,4 @@ area: config - Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode. - SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile. - SubMiner now seeds `config.jsonc` even when the default config directory already exists. +- First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 00a4dd0..b9b80d7 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -960,6 +960,7 @@ External-profile mode behavior: - SubMiner does not open its own Yomitan settings window in this mode. - SubMiner does not import, delete, or update dictionaries/settings in the external profile. - SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations. +- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one. ### Jellyfin diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 7aa1a96..e82af32 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -21,6 +21,7 @@ import { getSetupStatePath, readSetupState, } from '../../src/shared/setup-state.js'; +import { hasLauncherExternalYomitanProfileConfig } from '../config.js'; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_POLL_INTERVAL_MS = 500; @@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis const statePath = getSetupStatePath(configDir); const ready = await ensureLauncherSetupReady({ readSetupState: () => readSetupState(statePath), + isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(), launchSetupApp: () => { const setupArgs = ['--background', '--setup']; if (args.logLevel) { diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 8f28b62..4f3f090 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; +import { readExternalYomitanProfilePath } from './config.js'; import { getPluginConfigCandidates, parsePluginRuntimeConfigContent, @@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => { test('getDefaultSocketPath returns Windows named pipe default', () => { assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket'); }); + +test('readExternalYomitanProfilePath detects configured external profile paths', () => { + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: ' ~/.config/gsm_overlay ', + }, + }), + '~/.config/gsm_overlay', + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: ' ', + }, + }), + null, + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: null, + }), + null, + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: 123, + }, + } as never), + null, + ); +}); diff --git a/launcher/config.ts b/launcher/config.ts index 5cbc196..cd3bc16 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -17,6 +17,19 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; +export function readExternalYomitanProfilePath(root: Record | null): string | null { + const yomitan = + root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan) + ? (root.yomitan as Record) + : null; + const externalProfilePath = yomitan?.externalProfilePath; + if (typeof externalProfilePath !== 'string') { + return null; + } + const trimmed = externalProfilePath.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { const root = readLauncherMainConfigObject(); if (!root) return {}; @@ -29,6 +42,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { return parseLauncherJellyfinConfig(root); } +export function hasLauncherExternalYomitanProfileConfig(): boolean { + return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; +} + export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { return readPluginRuntimeConfigValue(logLevel); } diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index db3c8fb..2e6c1b8 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async () const sequence: Array = [ null, { - version: 2, + version: 3, status: 'in_progress', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async () windowsMpvShortcutLastStatus: 'unknown', }, { - version: 2, + version: 3, status: 'completed', completedAt: '2026-03-07T00:00:00.000Z', completionSource: 'user', + yomitanSetupMode: 'internal', lastSeenYomitanDictionaryCount: 1, pluginInstallStatus: 'skipped', pluginInstallPathSummary: null, @@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet if (reads === 1) return null; if (reads === 2) { return { - version: 2, + version: 3, status: 'in_progress', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet }; } return { - version: 2, + version: 3, status: 'completed', completedAt: '2026-03-07T00:00:00.000Z', completionSource: 'user', + yomitanSetupMode: 'internal', lastSeenYomitanDictionaryCount: 1, pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', @@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet assert.deepEqual(calls, ['launch']); }); +test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => { + const calls: string[] = []; + + const ready = await ensureLauncherSetupReady({ + readSetupState: () => null, + isExternalYomitanConfigured: () => true, + launchSetupApp: () => { + calls.push('launch'); + }, + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(ready, true); + assert.deepEqual(calls, []); +}); + test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { const result = await ensureLauncherSetupReady({ readSetupState: () => ({ - version: 2, + version: 3, status: 'cancelled', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts index 3d37fb7..ebd9b36 100644 --- a/launcher/setup-gate.ts +++ b/launcher/setup-gate.ts @@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: { export async function ensureLauncherSetupReady(deps: { readSetupState: () => SetupState | null; + isExternalYomitanConfigured?: () => boolean; launchSetupApp: () => void; sleep: (ms: number) => Promise; now: () => number; timeoutMs: number; pollIntervalMs: number; }): Promise { + if (deps.isExternalYomitanConfigured?.()) { + return true; + } if (isSetupCompleted(deps.readSetupState())) { return true; } diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 5b56d7a..c357f36 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns await runAppReadyRuntime(deps); assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true); - assert.equal(calls.includes('reloadConfig'), false); + assert.equal(calls.includes('reloadConfig'), true); assert.equal(calls.includes('getResolvedConfig'), false); assert.equal(calls.includes('getConfigWarnings'), false); assert.equal(calls.includes('setLogLevel:warn:config'), false); @@ -170,6 +170,8 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns assert.equal(calls.includes('loadYomitanExtension'), true); assert.equal(calls.includes('handleFirstRunSetup'), true); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs')); + assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('reloadConfig')); + assert.ok(calls.indexOf('reloadConfig') < calls.indexOf('handleFirstRunSetup')); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); }); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index e3a1771..67d78bf 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, detectPluginInstalled: () => { const installPaths = resolveDefaultMpvInstallPaths( process.platform, @@ -1834,6 +1835,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ configReady: snapshot.configReady, dictionaryCount: snapshot.dictionaryCount, canFinish: snapshot.canFinish, + externalYomitanConfigured: snapshot.externalYomitanConfigured, pluginStatus: snapshot.pluginStatus, pluginInstallPathSummary: snapshot.pluginInstallPathSummary, windowsMpvShortcuts: snapshot.windowsMpvShortcuts, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index ef7d0ce..af59fe1 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support const completed = await service.markSetupCompleted(); assert.equal(completed.state.status, 'completed'); assert.equal(completed.state.completionSource, 'user'); + assert.equal(completed.state.yomitanSetupMode, 'internal'); + }); +}); + +test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const initial = await service.ensureSetupStateInitialized(); + assert.equal(initial.canFinish, true); + + const completed = await service.markSetupCompleted(); + assert.equal(completed.state.status, 'completed'); + assert.equal(completed.state.yomitanSetupMode, 'external'); + assert.equal(completed.dictionaryCount, 0); + }); +}); + +test('setup service does not probe internal dictionaries when external yomitan is configured', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => { + throw new Error('should not probe internal dictionaries in external mode'); + }, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await service.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(snapshot.canFinish, true); + assert.equal(snapshot.externalYomitanConfigured, true); + assert.equal(snapshot.dictionaryCount, 0); + }); +}); + +test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + await service.ensureSetupStateInitialized(); + await service.markSetupCompleted(); + + const relaunched = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => false, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await relaunched.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'incomplete'); + assert.equal(snapshot.state.yomitanSetupMode, null); + assert.equal(snapshot.canFinish, false); + }); +}); + +test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + await service.ensureSetupStateInitialized(); + await service.markSetupCompleted(); + + const relaunched = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 2, + isExternalYomitanConfigured: () => false, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await relaunched.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(snapshot.canFinish, true); }); }); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 5541d7f..49f6d23 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -26,6 +26,7 @@ export interface SetupStatusSnapshot { configReady: boolean; dictionaryCount: number; canFinish: boolean; + externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot; @@ -139,10 +140,50 @@ function getEffectiveWindowsMpvShortcutPreferences( }; } +function isYomitanSetupSatisfied(options: { + configReady: boolean; + dictionaryCount: number; + externalYomitanConfigured: boolean; +}): boolean { + if (!options.configReady) { + return false; + } + return options.externalYomitanConfigured || options.dictionaryCount >= 1; +} + +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; installPlugin: () => Promise; detectWindowsMpvShortcuts?: () => @@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: { }; const buildSnapshot = async (state: SetupState, message: string | null = null) => { - const dictionaryCount = await deps.getYomitanDictionaryCount(); + const { configReady, dictionaryCount, externalYomitanConfigured } = + await resolveYomitanSetupStatus({ + configFilePaths, + getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + isExternalYomitanConfigured: deps.isExternalYomitanConfigured, + }); const pluginInstalled = await deps.detectPluginInstalled(); const detectedWindowsMpvShortcuts = isWindows ? await deps.detectWindowsMpvShortcuts?.() @@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: { state, installedWindowsMpvShortcuts, ); - const configReady = - fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); return { configReady, dictionaryCount, - canFinish: dictionaryCount >= 1, + canFinish: isYomitanSetupSatisfied({ + configReady, + dictionaryCount, + externalYomitanConfigured, + }), + externalYomitanConfigured, pluginStatus: getPluginStatus(state, pluginInstalled), pluginInstallPathSummary: state.pluginInstallPathSummary, windowsMpvShortcuts: { @@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: { return { ensureSetupStateInitialized: async () => { const state = readState(); - if (isSetupCompleted(state)) { + const { configReady, dictionaryCount, externalYomitanConfigured } = + await resolveYomitanSetupStatus({ + configFilePaths, + getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + isExternalYomitanConfigured: deps.isExternalYomitanConfigured, + }); + const yomitanSetupSatisfied = isYomitanSetupSatisfied({ + configReady, + dictionaryCount, + externalYomitanConfigured, + }); + if ( + isSetupCompleted(state) && + !(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied) + ) { completed = true; return refreshWithState(state); } - const dictionaryCount = await deps.getYomitanDictionaryCount(); - const configReady = - fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); - if (configReady && dictionaryCount >= 1) { + if (yomitanSetupSatisfied) { const completedState = writeState({ ...state, status: 'completed', completedAt: new Date().toISOString(), completionSource: 'legacy_auto_detected', + yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: dictionaryCount, }); return buildSnapshot(completedState); @@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: { status: state.status === 'cancelled' ? 'cancelled' : 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: dictionaryCount, }), ); @@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: { status: 'completed', completedAt: new Date().toISOString(), completionSource: 'user', + yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, }), ); diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 34b300b..6c631f2 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish configReady: true, dictionaryCount: 0, canFinish: false, + externalYomitanConfigured: false, pluginStatus: 'optional', pluginInstallPathSummary: null, windowsMpvShortcuts: { @@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in configReady: true, dictionaryCount: 1, canFinish: true, + externalYomitanConfigured: false, pluginStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', windowsMpvShortcuts: { @@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in assert.match(html, /Reinstall mpv plugin/); }); +test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => { + const html = buildFirstRunSetupHtml({ + configReady: true, + dictionaryCount: 0, + canFinish: true, + externalYomitanConfigured: true, + pluginStatus: 'optional', + pluginInstallPathSummary: null, + windowsMpvShortcuts: { + supported: false, + startMenuEnabled: true, + desktopEnabled: true, + startMenuInstalled: false, + desktopInstalled: false, + status: 'optional', + }, + message: null, + }); + + assert.match(html, /External profile configured/); + assert.match( + html, + /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./, + ); +}); + test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { action: 'refresh', @@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy configReady: false, dictionaryCount: 0, canFinish: false, + externalYomitanConfigured: false, pluginStatus: 'optional', pluginInstallPathSummary: null, windowsMpvShortcuts: { diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index d6682bd..d401e98 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel { configReady: boolean; dictionaryCount: number; canFinish: boolean; + externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: { @@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ` : ''; + 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 ` @@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
Yomitan dictionaries -
${model.dictionaryCount} installed
+
${escapeHtml(yomitanMeta)}
- ${renderStatusBadge( - model.dictionaryCount >= 1 ? 'Ready' : 'Missing', - model.dictionaryCount >= 1 ? 'ready' : 'warn', - )} + ${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
${windowsShortcutCard}
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
${model.message ? escapeHtml(model.message) : ''}
- + `; diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index 11ce420..80d5725 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -94,6 +94,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => { const state = createDefaultSetupState(); state.status = 'completed'; state.completionSource = 'user'; + state.yomitanSetupMode = 'internal'; state.lastSeenYomitanDictionaryCount = 2; writeSetupState(statePath, state); @@ -101,7 +102,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => { }); }); -test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { +test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => { withTempDir((root) => { const statePath = getSetupStatePath(root); fs.writeFileSync( @@ -118,10 +119,11 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { ); assert.deepEqual(readSetupState(statePath), { - version: 2, + version: 3, status: 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -134,6 +136,45 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { }); }); +test('readSetupState migrates completed v2 state to internal yomitan setup mode', () => { + withTempDir((root) => { + const statePath = getSetupStatePath(root); + fs.writeFileSync( + statePath, + JSON.stringify({ + version: 2, + status: 'completed', + completedAt: '2026-03-12T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { + startMenuEnabled: true, + desktopEnabled: true, + }, + windowsMpvShortcutLastStatus: 'unknown', + }), + ); + + assert.deepEqual(readSetupState(statePath), { + version: 3, + status: 'completed', + completedAt: '2026-03-12T00:00:00.000Z', + completionSource: 'user', + yomitanSetupMode: 'internal', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { + startMenuEnabled: true, + desktopEnabled: true, + }, + windowsMpvShortcutLastStatus: 'unknown', + }); + }); +}); + test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => { const linuxHomeDir = path.join(path.sep, 'tmp', 'home'); const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg'); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index 440a2af..ae13149 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -5,6 +5,7 @@ import { resolveConfigDir } from '../config/path-resolution'; export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled'; export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null; +export type SetupYomitanMode = 'internal' | 'external' | null; export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; @@ -14,10 +15,11 @@ export interface SetupWindowsMpvShortcutPreferences { } export interface SetupState { - version: 2; + version: 3; status: SetupStateStatus; completedAt: string | null; completionSource: SetupCompletionSource; + yomitanSetupMode: SetupYomitanMode; lastSeenYomitanDictionaryCount: number; pluginInstallStatus: SetupPluginInstallStatus; pluginInstallPathSummary: string | null; @@ -52,10 +54,11 @@ function asObject(value: unknown): Record | null { export function createDefaultSetupState(): SetupState { return { - version: 2, + version: 3, status: 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -74,11 +77,12 @@ export function normalizeSetupState(value: unknown): SetupState | null { const status = record.status; const pluginInstallStatus = record.pluginInstallStatus; const completionSource = record.completionSource; + const yomitanSetupMode = record.yomitanSetupMode; const windowsPrefs = asObject(record.windowsMpvShortcutPreferences); const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus; if ( - (version !== 1 && version !== 2) || + (version !== 1 && version !== 2 && version !== 3) || (status !== 'incomplete' && status !== 'in_progress' && status !== 'completed' && @@ -94,16 +98,26 @@ export function normalizeSetupState(value: unknown): SetupState | null { windowsMpvShortcutLastStatus !== 'failed') || (completionSource !== null && completionSource !== 'user' && - completionSource !== 'legacy_auto_detected') + completionSource !== 'legacy_auto_detected') || + (version === 3 && + yomitanSetupMode !== null && + yomitanSetupMode !== 'internal' && + yomitanSetupMode !== 'external') ) { return null; } return { - version: 2, + version: 3, status, completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, completionSource, + yomitanSetupMode: + version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external') + ? yomitanSetupMode + : status === 'completed' + ? 'internal' + : null, lastSeenYomitanDictionaryCount: typeof record.lastSeenYomitanDictionaryCount === 'number' && Number.isFinite(record.lastSeenYomitanDictionaryCount) &&