mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
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:
@@ -4,3 +4,4 @@ area: config
|
|||||||
- Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
|
- 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 reuses external Yomitan dictionaries/settings without writing back to that profile.
|
||||||
- SubMiner now seeds `config.jsonc` even when the default config directory already exists.
|
- 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.
|
||||||
|
|||||||
@@ -960,6 +960,7 @@ External-profile mode behavior:
|
|||||||
- SubMiner does not open its own Yomitan settings window in this mode.
|
- 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 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.
|
- 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
|
### Jellyfin
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
readSetupState,
|
||||||
} from '../../src/shared/setup-state.js';
|
} from '../../src/shared/setup-state.js';
|
||||||
|
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
@@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const statePath = getSetupStatePath(configDir);
|
const statePath = getSetupStatePath(configDir);
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
|
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
const setupArgs = ['--background', '--setup'];
|
const setupArgs = ['--background', '--setup'];
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
|
import { readExternalYomitanProfilePath } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPluginConfigCandidates,
|
getPluginConfigCandidates,
|
||||||
parsePluginRuntimeConfigContent,
|
parsePluginRuntimeConfigContent,
|
||||||
@@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
|||||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
|||||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.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 {
|
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
||||||
const root = readLauncherMainConfigObject();
|
const root = readLauncherMainConfigObject();
|
||||||
if (!root) return {};
|
if (!root) return {};
|
||||||
@@ -29,6 +42,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
|||||||
return parseLauncherJellyfinConfig(root);
|
return parseLauncherJellyfinConfig(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||||
|
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
return readPluginRuntimeConfigValue(logLevel);
|
return readPluginRuntimeConfigValue(logLevel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
|||||||
const sequence: Array<SetupState | null> = [
|
const sequence: Array<SetupState | null> = [
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
|||||||
windowsMpvShortcutLastStatus: 'unknown',
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
pluginInstallStatus: 'skipped',
|
pluginInstallStatus: 'skipped',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
if (reads === 1) return null;
|
if (reads === 1) return null;
|
||||||
if (reads === 2) {
|
if (reads === 2) {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
@@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
|||||||
assert.deepEqual(calls, ['launch']);
|
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 () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: () => ({
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
|
|
||||||
export async function ensureLauncherSetupReady(deps: {
|
export async function ensureLauncherSetupReady(deps: {
|
||||||
readSetupState: () => SetupState | null;
|
readSetupState: () => SetupState | null;
|
||||||
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
launchSetupApp: () => void;
|
launchSetupApp: () => void;
|
||||||
sleep: (ms: number) => Promise<void>;
|
sleep: (ms: number) => Promise<void>;
|
||||||
now: () => number;
|
now: () => number;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
|
if (deps.isExternalYomitanConfigured?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isSetupCompleted(deps.readSetupState())) {
|
if (isSetupCompleted(deps.readSetupState())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
await runAppReadyRuntime(deps);
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
|
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('getResolvedConfig'), false);
|
||||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
assert.equal(calls.includes('getConfigWarnings'), false);
|
||||||
assert.equal(calls.includes('setLogLevel:warn:config'), 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('loadYomitanExtension'), true);
|
||||||
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
||||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
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('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
|
||||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
deps.ensureDefaultConfigBootstrap();
|
deps.ensureDefaultConfigBootstrap();
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
return;
|
return;
|
||||||
@@ -194,6 +195,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
|
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
|
|||||||
@@ -693,6 +693,7 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
});
|
});
|
||||||
return dictionaries.length;
|
return dictionaries.length;
|
||||||
},
|
},
|
||||||
|
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||||
detectPluginInstalled: () => {
|
detectPluginInstalled: () => {
|
||||||
const installPaths = resolveDefaultMpvInstallPaths(
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
process.platform,
|
process.platform,
|
||||||
@@ -1834,6 +1835,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
configReady: snapshot.configReady,
|
configReady: snapshot.configReady,
|
||||||
dictionaryCount: snapshot.dictionaryCount,
|
dictionaryCount: snapshot.dictionaryCount,
|
||||||
canFinish: snapshot.canFinish,
|
canFinish: snapshot.canFinish,
|
||||||
|
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||||
pluginStatus: snapshot.pluginStatus,
|
pluginStatus: snapshot.pluginStatus,
|
||||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||||
|
|||||||
@@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support
|
|||||||
const completed = await service.markSetupCompleted();
|
const completed = await service.markSetupCompleted();
|
||||||
assert.equal(completed.state.status, 'completed');
|
assert.equal(completed.state.status, 'completed');
|
||||||
assert.equal(completed.state.completionSource, 'user');
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface SetupStatusSnapshot {
|
|||||||
configReady: boolean;
|
configReady: boolean;
|
||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
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: {
|
export function createFirstRunSetupService(deps: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
getYomitanDictionaryCount: () => Promise<number>;
|
getYomitanDictionaryCount: () => Promise<number>;
|
||||||
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||||
installPlugin: () => Promise<PluginInstallResult>;
|
installPlugin: () => Promise<PluginInstallResult>;
|
||||||
detectWindowsMpvShortcuts?: () =>
|
detectWindowsMpvShortcuts?: () =>
|
||||||
@@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
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 pluginInstalled = await deps.detectPluginInstalled();
|
||||||
const detectedWindowsMpvShortcuts = isWindows
|
const detectedWindowsMpvShortcuts = isWindows
|
||||||
? await deps.detectWindowsMpvShortcuts?.()
|
? await deps.detectWindowsMpvShortcuts?.()
|
||||||
@@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: {
|
|||||||
state,
|
state,
|
||||||
installedWindowsMpvShortcuts,
|
installedWindowsMpvShortcuts,
|
||||||
);
|
);
|
||||||
const configReady =
|
|
||||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
|
||||||
return {
|
return {
|
||||||
configReady,
|
configReady,
|
||||||
dictionaryCount,
|
dictionaryCount,
|
||||||
canFinish: dictionaryCount >= 1,
|
canFinish: isYomitanSetupSatisfied({
|
||||||
|
configReady,
|
||||||
|
dictionaryCount,
|
||||||
|
externalYomitanConfigured,
|
||||||
|
}),
|
||||||
|
externalYomitanConfigured,
|
||||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: {
|
|||||||
return {
|
return {
|
||||||
ensureSetupStateInitialized: async () => {
|
ensureSetupStateInitialized: async () => {
|
||||||
const state = readState();
|
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;
|
completed = true;
|
||||||
return refreshWithState(state);
|
return refreshWithState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dictionaryCount = await deps.getYomitanDictionaryCount();
|
if (yomitanSetupSatisfied) {
|
||||||
const configReady =
|
|
||||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
|
||||||
if (configReady && dictionaryCount >= 1) {
|
|
||||||
const completedState = writeState({
|
const completedState = writeState({
|
||||||
...state,
|
...state,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
completionSource: 'legacy_auto_detected',
|
completionSource: 'legacy_auto_detected',
|
||||||
|
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
|
||||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||||
});
|
});
|
||||||
return buildSnapshot(completedState);
|
return buildSnapshot(completedState);
|
||||||
@@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: dictionaryCount,
|
lastSeenYomitanDictionaryCount: dictionaryCount,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
completionSource: 'user',
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
|
||||||
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
configReady: true,
|
configReady: true,
|
||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'optional',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
configReady: true,
|
configReady: true,
|
||||||
dictionaryCount: 1,
|
dictionaryCount: 1,
|
||||||
canFinish: true,
|
canFinish: true,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'installed',
|
pluginStatus: 'installed',
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
assert.match(html, /Reinstall mpv plugin/);
|
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', () => {
|
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||||
action: 'refresh',
|
action: 'refresh',
|
||||||
@@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
|||||||
configReady: false,
|
configReady: false,
|
||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'optional',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel {
|
|||||||
configReady: boolean;
|
configReady: boolean;
|
||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</div>`
|
</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>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div>
|
<div>
|
||||||
<strong>Yomitan dictionaries</strong>
|
<strong>Yomitan dictionaries</strong>
|
||||||
<div class="meta">${model.dictionaryCount} installed</div>
|
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||||
</div>
|
</div>
|
||||||
${renderStatusBadge(
|
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||||
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
|
|
||||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
${windowsShortcutCard}
|
${windowsShortcutCard}
|
||||||
<div class="actions">
|
<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>
|
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
|||||||
const state = createDefaultSetupState();
|
const state = createDefaultSetupState();
|
||||||
state.status = 'completed';
|
state.status = 'completed';
|
||||||
state.completionSource = 'user';
|
state.completionSource = 'user';
|
||||||
|
state.yomitanSetupMode = 'internal';
|
||||||
state.lastSeenYomitanDictionaryCount = 2;
|
state.lastSeenYomitanDictionaryCount = 2;
|
||||||
writeSetupState(statePath, state);
|
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) => {
|
withTempDir((root) => {
|
||||||
const statePath = getSetupStatePath(root);
|
const statePath = getSetupStatePath(root);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -118,10 +119,11 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(readSetupState(statePath), {
|
assert.deepEqual(readSetupState(statePath), {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'incomplete',
|
status: 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
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', () => {
|
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
||||||
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
||||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveConfigDir } from '../config/path-resolution';
|
|||||||
|
|
||||||
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
|
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
||||||
|
export type SetupYomitanMode = 'internal' | 'external' | null;
|
||||||
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||||
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||||
|
|
||||||
@@ -14,10 +15,11 @@ export interface SetupWindowsMpvShortcutPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupState {
|
export interface SetupState {
|
||||||
version: 2;
|
version: 3;
|
||||||
status: SetupStateStatus;
|
status: SetupStateStatus;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
completionSource: SetupCompletionSource;
|
completionSource: SetupCompletionSource;
|
||||||
|
yomitanSetupMode: SetupYomitanMode;
|
||||||
lastSeenYomitanDictionaryCount: number;
|
lastSeenYomitanDictionaryCount: number;
|
||||||
pluginInstallStatus: SetupPluginInstallStatus;
|
pluginInstallStatus: SetupPluginInstallStatus;
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
@@ -52,10 +54,11 @@ function asObject(value: unknown): Record<string, unknown> | null {
|
|||||||
|
|
||||||
export function createDefaultSetupState(): SetupState {
|
export function createDefaultSetupState(): SetupState {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status: 'incomplete',
|
status: 'incomplete',
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
completionSource: null,
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
pluginInstallStatus: 'unknown',
|
pluginInstallStatus: 'unknown',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
@@ -74,11 +77,12 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
|||||||
const status = record.status;
|
const status = record.status;
|
||||||
const pluginInstallStatus = record.pluginInstallStatus;
|
const pluginInstallStatus = record.pluginInstallStatus;
|
||||||
const completionSource = record.completionSource;
|
const completionSource = record.completionSource;
|
||||||
|
const yomitanSetupMode = record.yomitanSetupMode;
|
||||||
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
||||||
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(version !== 1 && version !== 2) ||
|
(version !== 1 && version !== 2 && version !== 3) ||
|
||||||
(status !== 'incomplete' &&
|
(status !== 'incomplete' &&
|
||||||
status !== 'in_progress' &&
|
status !== 'in_progress' &&
|
||||||
status !== 'completed' &&
|
status !== 'completed' &&
|
||||||
@@ -94,16 +98,26 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
|||||||
windowsMpvShortcutLastStatus !== 'failed') ||
|
windowsMpvShortcutLastStatus !== 'failed') ||
|
||||||
(completionSource !== null &&
|
(completionSource !== null &&
|
||||||
completionSource !== 'user' &&
|
completionSource !== 'user' &&
|
||||||
completionSource !== 'legacy_auto_detected')
|
completionSource !== 'legacy_auto_detected') ||
|
||||||
|
(version === 3 &&
|
||||||
|
yomitanSetupMode !== null &&
|
||||||
|
yomitanSetupMode !== 'internal' &&
|
||||||
|
yomitanSetupMode !== 'external')
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 3,
|
||||||
status,
|
status,
|
||||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||||
completionSource,
|
completionSource,
|
||||||
|
yomitanSetupMode:
|
||||||
|
version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external')
|
||||||
|
? yomitanSetupMode
|
||||||
|
: status === 'completed'
|
||||||
|
? 'internal'
|
||||||
|
: null,
|
||||||
lastSeenYomitanDictionaryCount:
|
lastSeenYomitanDictionaryCount:
|
||||||
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
|
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
|
||||||
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
|
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
|
||||||
|
|||||||
Reference in New Issue
Block a user