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
This commit is contained in:
2026-03-12 00:28:01 -07:00
parent 6ff89b9227
commit beb48ab0cb
16 changed files with 427 additions and 28 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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,
);
});

View File

@@ -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<string, unknown> | null): string | null {
const yomitan =
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
? (root.yomitan as Record<string, unknown>)
: 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);
}

View File

@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
const sequence: Array<SetupState | null> = [
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,

View File

@@ -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<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
if (isSetupCompleted(deps.readSetupState())) {
return true;
}

View File

@@ -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'));
});

View File

@@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.ensureDefaultConfigBootstrap();
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
@@ -194,6 +195,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);

View File

@@ -693,6 +693,7 @@ const firstRunSetupService = createFirstRunSetupService({
});
return dictionaries.length;
},
isExternalYomitanConfigured: () => 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,

View File

@@ -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);
});
});

View File

@@ -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<number>;
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<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
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,
}),
);

View File

@@ -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: {

View File

@@ -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 {
</div>`
: '';
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 `<!doctype html>
<html>
<head>
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${windowsShortcutCard}
<div class="actions">
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
</main>
</body>
</html>`;

View File

@@ -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');

View File

@@ -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<string, unknown> | 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) &&