diff --git a/README.md b/README.md index 236f45af..182479c4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Look up words with Yomitan, export to Anki in one key, track your immersion — SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect. -On Windows, the recommended playback entry point is the optional `SubMiner mpv` shortcut created during setup. It launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use the shortcut. +On Windows, the recommended playback entry point is the optional `SubMiner mpv` shortcut created during setup. First-run setup requires the mpv plugin before it can finish. The shortcut launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use it. ## Features diff --git a/changes/270-first-run-setup-requires-mpv-plugin.md b/changes/270-first-run-setup-requires-mpv-plugin.md new file mode 100644 index 00000000..afa34b41 --- /dev/null +++ b/changes/270-first-run-setup-requires-mpv-plugin.md @@ -0,0 +1,5 @@ +type: changed +area: setup + +- Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed. +- Updated the Windows setup copy to make the `SubMiner mpv` shortcut the recommended playback entry point after setup completes. diff --git a/docs-site/installation.md b/docs-site/installation.md index 5d2cb160..3713620e 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -171,7 +171,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re ### Windows Usage Notes -- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts. On Windows, that shortcut is the recommended way to launch mpv playback with SubMiner defaults. +- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts. On Windows, that shortcut is the recommended way to launch mpv playback with SubMiner defaults. - `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly, including the Windows-safe subtitle search paths that skip the extra current-directory scan; they do not require an `mpv.conf` profile named `subminer`. - First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location. - Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows. diff --git a/docs-site/usage.md b/docs-site/usage.md index 71efecfa..de53099d 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -118,6 +118,7 @@ SubMiner.AppImage --help # Show all options ### Windows mpv Shortcut If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly. +First-run setup requires the mpv plugin before it can finish, so the shortcut is the normal Windows playback entry point after setup completes. You can use it three ways: @@ -157,12 +158,12 @@ SubMiner.AppImage --setup Setup flow: - config file: create the default config directory and prefer `config.jsonc` -- plugin status: install or skip the bundled mpv plugin +- plugin status: install the bundled mpv plugin before finishing setup - Yomitan shortcut: open bundled Yomitan settings directly from the setup window - dictionary check: ensure at least one bundled Yomitan dictionary is available - Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`) - refresh: re-check plugin + dictionary state without restarting -- `Finish setup` stays disabled until dictionary availability is detected +- `Finish setup` stays disabled until the mpv plugin is installed and dictionary availability is detected - finish action writes setup completion state and suppresses future auto-open prompts AniList character dictionary auto-sync (optional): diff --git a/src/main.ts b/src/main.ts index 8f4ff475..ea6be4b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2238,18 +2238,21 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ firstRunSetupMessage = snapshot.message; return; } - if (submission.action === 'skip-plugin') { - await firstRunSetupService.skipPluginInstall(); - firstRunSetupMessage = 'mpv plugin installation skipped.'; - return; - } const snapshot = await firstRunSetupService.markSetupCompleted(); if (snapshot.state.status === 'completed') { firstRunSetupMessage = null; return { closeWindow: true }; } - firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.'; + if (snapshot.pluginStatus !== 'installed') { + firstRunSetupMessage = 'Install the mpv plugin before finishing setup.'; + return; + } + if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) { + firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.'; + return; + } + firstRunSetupMessage = 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; return; }, markSetupInProgress: async () => { diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index aef224ce..b60a86c2 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -88,7 +88,7 @@ test('setup service auto-completes legacy installs with config and dictionaries' const service = createFirstRunSetupService({ configDir, getYomitanDictionaryCount: async () => 2, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -106,17 +106,18 @@ test('setup service auto-completes legacy installs with config and dictionaries' }); }); -test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => { +test('setup service requires mpv plugin install before finish', async () => { await withTempDir(async (root) => { const configDir = path.join(root, 'SubMiner'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); let dictionaryCount = 0; + let pluginInstalled = false; const service = createFirstRunSetupService({ configDir, getYomitanDictionaryCount: async () => dictionaryCount, - detectPluginInstalled: () => false, + detectPluginInstalled: () => pluginInstalled, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -130,13 +131,11 @@ test('setup service requires explicit finish for incomplete installs and support assert.equal(initial.state.status, 'incomplete'); assert.equal(initial.canFinish, false); - const skipped = await service.skipPluginInstall(); - assert.equal(skipped.state.pluginInstallStatus, 'skipped'); - const installed = await service.installMpvPlugin(); assert.equal(installed.state.pluginInstallStatus, 'installed'); assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv'); + pluginInstalled = true; dictionaryCount = 1; const refreshed = await service.refreshStatus(); assert.equal(refreshed.canFinish, true); @@ -158,7 +157,7 @@ test('setup service allows completion without internal dictionaries when externa configDir, getYomitanDictionaryCount: async () => 0, isExternalYomitanConfigured: () => true, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -190,7 +189,7 @@ test('setup service does not probe internal dictionaries when external yomitan i throw new Error('should not probe internal dictionaries in external mode'); }, isExternalYomitanConfigured: () => true, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -218,7 +217,7 @@ test('setup service reopens when external-yomitan completion later has no extern configDir, getYomitanDictionaryCount: async () => 0, isExternalYomitanConfigured: () => true, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -235,7 +234,7 @@ test('setup service reopens when external-yomitan completion later has no extern configDir, getYomitanDictionaryCount: async () => 0, isExternalYomitanConfigured: () => false, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -262,7 +261,7 @@ test('setup service keeps completed when external-yomitan completion later has i configDir, getYomitanDictionaryCount: async () => 0, isExternalYomitanConfigured: () => true, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -279,7 +278,7 @@ test('setup service keeps completed when external-yomitan completion later has i configDir, getYomitanDictionaryCount: async () => 2, isExternalYomitanConfigured: () => false, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -304,7 +303,7 @@ test('setup service marks cancelled when popup closes before completion', async const service = createFirstRunSetupService({ configDir, getYomitanDictionaryCount: async () => 0, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -331,7 +330,7 @@ test('setup service reflects detected Windows mpv shortcuts before preferences a platform: 'win32', configDir, getYomitanDictionaryCount: async () => 0, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', @@ -364,7 +363,7 @@ test('setup service persists Windows mpv shortcut preferences and status with on platform: 'win32', configDir, getYomitanDictionaryCount: async () => 0, - detectPluginInstalled: () => false, + detectPluginInstalled: () => true, installPlugin: async () => ({ ok: true, pluginInstallStatus: 'installed', diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index bd6f0772..91ccf000 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -27,7 +27,7 @@ export interface SetupStatusSnapshot { dictionaryCount: number; canFinish: boolean; externalYomitanConfigured: boolean; - pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; + pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot; message: string | null; @@ -48,7 +48,6 @@ export interface FirstRunSetupService { markSetupInProgress: () => Promise; markSetupCancelled: () => Promise; markSetupCompleted: () => Promise; - skipPluginInstall: () => Promise; installMpvPlugin: () => Promise; configureWindowsMpvShortcuts: (preferences: { startMenuEnabled: boolean; @@ -108,9 +107,8 @@ function getPluginStatus( pluginInstalled: boolean, ): SetupStatusSnapshot['pluginStatus'] { if (pluginInstalled) return 'installed'; - if (state.pluginInstallStatus === 'skipped') return 'skipped'; if (state.pluginInstallStatus === 'failed') return 'failed'; - return 'optional'; + return 'required'; } function getWindowsMpvShortcutStatus( @@ -230,11 +228,13 @@ export function createFirstRunSetupService(deps: { return { configReady, dictionaryCount, - canFinish: isYomitanSetupSatisfied({ - configReady, - dictionaryCount, - externalYomitanConfigured, - }), + canFinish: + pluginInstalled && + isYomitanSetupSatisfied({ + configReady, + dictionaryCount, + externalYomitanConfigured, + }), externalYomitanConfigured, pluginStatus: getPluginStatus(state, pluginInstalled), pluginInstallPathSummary: state.pluginInstallPathSummary, @@ -347,8 +347,6 @@ export function createFirstRunSetupService(deps: { }), ); }, - skipPluginInstall: async () => - refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })), installMpvPlugin: async () => { const result = await deps.installPlugin(); return refreshWithState( diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 6c631f28..9eb07d77 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -14,7 +14,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish dictionaryCount: 0, canFinish: false, externalYomitanConfigured: false, - pluginStatus: 'optional', + pluginStatus: 'required', pluginInstallPathSummary: null, windowsMpvShortcuts: { supported: false, @@ -29,6 +29,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish assert.match(html, /SubMiner setup/); assert.match(html, /Install mpv plugin/); + assert.match(html, /Required before SubMiner setup can finish/); assert.match(html, /Open Yomitan Settings/); assert.match(html, /Finish setup/); assert.match(html, /disabled/); @@ -62,7 +63,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena dictionaryCount: 0, canFinish: true, externalYomitanConfigured: true, - pluginStatus: 'optional', + pluginStatus: 'installed', pluginInstallPathSummary: null, windowsMpvShortcuts: { supported: false, @@ -76,16 +77,14 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena }); assert.match(html, /External profile configured/); - assert.match( - html, - /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./, - ); + 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', }); + assert.equal(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), null); assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null); }); @@ -146,7 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy dictionaryCount: 0, canFinish: false, externalYomitanConfigured: false, - pluginStatus: 'optional', + pluginStatus: 'required', pluginInstallPathSummary: null, windowsMpvShortcuts: { supported: false, diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index d401e980..dbcc9626 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -19,7 +19,6 @@ export type FirstRunSetupAction = | 'configure-windows-mpv-shortcuts' | 'open-yomitan-settings' | 'refresh' - | 'skip-plugin' | 'finish'; export interface FirstRunSetupSubmission { @@ -33,7 +32,7 @@ export interface FirstRunSetupHtmlModel { dictionaryCount: number; canFinish: boolean; externalYomitanConfigured: boolean; - pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; + pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: { supported: boolean; @@ -64,19 +63,15 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { const pluginLabel = model.pluginStatus === 'installed' ? 'Installed' - : model.pluginStatus === 'skipped' - ? 'Skipped' - : model.pluginStatus === 'failed' - ? 'Failed' - : 'Optional'; + : model.pluginStatus === 'failed' + ? 'Failed' + : 'Required'; const pluginTone = model.pluginStatus === 'installed' ? 'ready' : model.pluginStatus === 'failed' ? 'danger' - : model.pluginStatus === 'skipped' - ? 'muted' - : 'warn'; + : 'warn'; const windowsShortcutLabel = model.windowsMpvShortcuts.status === 'installed' ? 'Installed' @@ -129,8 +124,10 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ? '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.'; + ? model.pluginStatus === 'installed' + ? '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 the mpv plugin is installed. If you later launch without yomitan.externalProfilePath, setup will also require at least one internal dictionary.' + : 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.'; return ` @@ -269,6 +266,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
mpv plugin
${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}
+
Required before SubMiner setup can finish.
${renderStatusBadge(pluginLabel, pluginTone)} @@ -284,7 +282,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { -
${model.message ? escapeHtml(model.message) : ''}
@@ -305,7 +302,6 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu action !== 'configure-windows-mpv-shortcuts' && action !== 'open-yomitan-settings' && action !== 'refresh' && - action !== 'skip-plugin' && action !== 'finish' ) { return null;