diff --git a/README.md b/README.md index 9402549..f2c5350 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla - **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync - **Immersion tracking** — SQLite-powered stats on your watch time and mining activity - **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup +- **Annotated websocket API** — Dedicated annotation feed can serve bundled texthooker or external clients with rendered `sentence` HTML plus structured `tokens` - **Jellyfin integration** — Remote playback setup, cast device mode, and direct playback launch - **AniList progress** — Track episode completion and push watching progress automatically @@ -55,28 +56,34 @@ chmod +x ~/.local/bin/subminer **From source** or **macOS** — see the [installation guide](https://docs.subminer.moe/installation#from-source). -### 2. Install the mpv plugin and configuration file +### 2. Launch the app once ```bash -wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz -tar -xzf /tmp/subminer-assets.tar.gz -C /tmp -mkdir -p ~/.config/mpv/scripts/subminer -mkdir -p ~/.config/mpv/script-opts -cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/ -cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ -mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc +SubMiner.AppImage ``` -### 3. Set up Yomitan Dictionaries +On first launch, SubMiner now: -```bash -subminer app --yomitan -``` +- starts in the tray/background +- creates the default config directory and `config.jsonc` +- opens a compact setup popup +- can install the mpv plugin to the default mpv scripts location for you +- links directly to Yomitan settings so you can install dictionaries before finishing setup + +Existing installs that already have a valid config plus at least one Yomitan dictionary are auto-detected as complete and will not be re-prompted. + +### 3. Finish setup + +- click `Install mpv plugin` if you want the default plugin auto-start flow +- click `Open Yomitan Settings` and install at least one dictionary +- click `Refresh status` +- click `Finish setup` + +The mpv plugin step is optional. Yomitan must report at least one installed dictionary before setup can be completed. ### 4. Mine ```bash -subminer app --start --background subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no ``` diff --git a/backlog/tasks/task-106 - Add-first-run-setup-gate-and-auto-install-flow.md b/backlog/tasks/task-106 - Add-first-run-setup-gate-and-auto-install-flow.md new file mode 100644 index 0000000..499b2d8 --- /dev/null +++ b/backlog/tasks/task-106 - Add-first-run-setup-gate-and-auto-install-flow.md @@ -0,0 +1,69 @@ +--- +id: TASK-106 +title: Add first-run setup gate and auto-install flow +status: Done +assignee: + - codex +created_date: '2026-03-07 06:10' +updated_date: '2026-03-07 06:20' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/main.ts + - /home/sudacode/projects/japanese/SubMiner/src/shared/setup-state.ts + - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/first-run-setup-service.ts + - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/first-run-setup-window.ts + - /home/sudacode/projects/japanese/SubMiner/launcher/commands/playback-command.ts +priority: high +ordinal: 10600 +--- + +## Description + + +Replace the current manual install flow with a first-run setup gate: + +- bootstrap the default config dir/config file automatically +- detect legacy installs and mark them complete when config + Yomitan dictionaries are already present +- open a compact Catppuccin Macchiato setup popup for incomplete installs +- optionally install the mpv plugin into the default mpv location +- block launcher playback until setup completes, then resume the original playback flow + + +## Acceptance Criteria + + +- [x] #1 First app launch seeds the default config dir/config file without manual copy steps. +- [x] #2 Existing installs with config plus at least one Yomitan dictionary are auto-detected as already complete. +- [x] #3 Incomplete installs get a first-run setup popup with mpv plugin install, Yomitan settings, refresh, skip, and finish actions. +- [x] #4 Launcher playback waits for setup completion and does not start mpv while setup is incomplete. +- [x] #5 Plugin assets are packaged into the Electron bundle and regression tests cover the new flow. + + +## Implementation Notes + + +Added shared setup-state/config/mpv path helpers so Electron and launcher read the same onboarding state file. + +Introduced a first-run setup service plus compact BrowserWindow popup using Catppuccin Macchiato styling. The popup supports optional mpv plugin install, opening Yomitan settings, status refresh, skip-plugin, and gated finish once at least one Yomitan dictionary is installed. + +Electron startup now bootstraps a default config file, auto-detects legacy-complete installs, adds `--setup` CLI support, exposes a tray `Complete Setup` action while incomplete, and avoids reopening setup once completion is recorded. + +Launcher playback now checks the shared setup-state file before starting mpv. If setup is incomplete, it launches the app with `--background --setup`, waits for completion, and only then proceeds. + +Verification: + +- `bun run typecheck` +- `bun run test:fast` + + +## Final Summary + + +SubMiner now supports a download-and-launch install flow. + +- First launch auto-creates config and opens setup only when needed. +- Existing users with working installs are silently migrated to completed setup. +- The setup popup handles optional mpv plugin install and Yomitan dictionary readiness. +- Launcher playback is gated on setup completion and resumes automatically afterward. + diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 83a33d4..c73040f 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import os from 'node:os'; +import { spawn } from 'node:child_process'; import { fail, log } from '../log.js'; import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js'; import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; @@ -13,6 +15,11 @@ import { import { generateYoutubeSubtitles } from '../youtube.js'; import type { Args } from '../types.js'; import type { LauncherCommandContext } from './context.js'; +import { ensureLauncherSetupReady } from '../setup-gate.js'; +import { getDefaultConfigDir, getSetupStatePath, readSetupState } from '../../src/shared/setup-state.js'; + +const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const SETUP_POLL_INTERVAL_MS = 500; function checkDependencies(args: Args): void { const missing: string[] = []; @@ -84,12 +91,47 @@ function registerCleanup(context: LauncherCommandContext): void { }); } +async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise { + const { args, appPath } = context; + if (!appPath) return; + + const configDir = getDefaultConfigDir({ + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + }); + const statePath = getSetupStatePath(configDir); + const ready = await ensureLauncherSetupReady({ + readSetupState: () => readSetupState(statePath), + launchSetupApp: () => { + const setupArgs = ['--background', '--setup']; + if (args.logLevel) { + setupArgs.push('--log-level', args.logLevel); + } + const child = spawn(appPath, setupArgs, { + detached: true, + stdio: 'ignore', + }); + child.unref(); + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), + timeoutMs: SETUP_WAIT_TIMEOUT_MS, + pollIntervalMs: SETUP_POLL_INTERVAL_MS, + }); + + if (!ready) { + fail('SubMiner setup is incomplete. Complete setup in the app, then retry playback.'); + } +} + export async function runPlaybackCommand(context: LauncherCommandContext): Promise { const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; if (!appPath) { fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); } + await ensurePlaybackSetupReady(context); + if (!args.target) { checkPickerDependencies(args); } diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts new file mode 100644 index 0000000..2f32e5f --- /dev/null +++ b/launcher/setup-gate.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate'; +import type { SetupState } from '../src/shared/setup-state'; + +test('waitForSetupCompletion resolves completed and cancelled states', async () => { + const sequence: Array = [ + null, + { + version: 1, + status: 'in_progress', + completedAt: null, + completionSource: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + }, + { + version: 1, + status: 'completed', + completedAt: '2026-03-07T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'skipped', + pluginInstallPathSummary: null, + }, + ]; + + const result = await waitForSetupCompletion({ + readSetupState: () => sequence.shift() ?? null, + sleep: async () => undefined, + now: (() => { + let value = 0; + return () => (value += 100); + })(), + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(result, 'completed'); +}); + +test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => { + const calls: string[] = []; + let reads = 0; + + const ready = await ensureLauncherSetupReady({ + readSetupState: () => { + reads += 1; + if (reads === 1) return null; + if (reads === 2) { + return { + version: 1, + status: 'in_progress', + completedAt: null, + completionSource: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + }; + } + return { + version: 1, + status: 'completed', + completedAt: '2026-03-07T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + }; + }, + launchSetupApp: () => { + calls.push('launch'); + }, + sleep: async () => undefined, + now: (() => { + let value = 0; + return () => (value += 100); + })(), + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(ready, true); + assert.deepEqual(calls, ['launch']); +}); + +test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { + const result = await ensureLauncherSetupReady({ + readSetupState: () => ({ + version: 1, + status: 'cancelled', + completedAt: null, + completionSource: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + }), + launchSetupApp: () => undefined, + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(result, false); +}); diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts new file mode 100644 index 0000000..3d37fb7 --- /dev/null +++ b/launcher/setup-gate.ts @@ -0,0 +1,41 @@ +import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js'; + +export async function waitForSetupCompletion(deps: { + readSetupState: () => SetupState | null; + sleep: (ms: number) => Promise; + now: () => number; + timeoutMs: number; + pollIntervalMs: number; +}): Promise<'completed' | 'cancelled' | 'timeout'> { + const deadline = deps.now() + deps.timeoutMs; + + while (deps.now() <= deadline) { + const state = deps.readSetupState(); + if (isSetupCompleted(state)) { + return 'completed'; + } + if (state?.status === 'cancelled') { + return 'cancelled'; + } + await deps.sleep(deps.pollIntervalMs); + } + + return 'timeout'; +} + +export async function ensureLauncherSetupReady(deps: { + readSetupState: () => SetupState | null; + launchSetupApp: () => void; + sleep: (ms: number) => Promise; + now: () => number; + timeoutMs: number; + pollIntervalMs: number; +}): Promise { + if (isSetupCompleted(deps.readSetupState())) { + return true; + } + + deps.launchSetupApp(); + const result = await waitForSetupCompletion(deps); + return result === 'completed'; +} diff --git a/package.json b/package.json index 6879137..390d69e 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,13 @@ "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "format": "prettier --write .", "format:check": "prettier --check .", - "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts", - "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js", + "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts", + "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", @@ -128,6 +128,14 @@ "from": "assets", "to": "assets" }, + { + "from": "plugin/subminer", + "to": "plugin/subminer" + }, + { + "from": "plugin/subminer.conf", + "to": "plugin/subminer.conf" + }, { "from": "dist/scripts/get-mpv-window-macos", "to": "scripts/get-mpv-window-macos" diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index caa8cac..c0bc5d0 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -169,4 +169,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(background.background, true); assert.equal(hasExplicitCommand(background), true); assert.equal(shouldStartApp(background), true); + + const setup = parseArgs(['--setup']); + assert.equal((setup as typeof setup & { setup?: boolean }).setup, true); + assert.equal(hasExplicitCommand(setup), true); + assert.equal(shouldStartApp(setup), true); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 1bfb828..a10acf0 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -5,6 +5,7 @@ export interface CliArgs { toggle: boolean; toggleVisibleOverlay: boolean; settings: boolean; + setup: boolean; show: boolean; hide: boolean; showVisibleOverlay: boolean; @@ -71,6 +72,7 @@ export function parseArgs(argv: string[]): CliArgs { toggle: false, toggleVisibleOverlay: false, settings: false, + setup: false, show: false, hide: false, showVisibleOverlay: false, @@ -125,6 +127,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle') args.toggle = true; else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; else if (arg === '--settings' || arg === '--yomitan') args.settings = true; + else if (arg === '--setup') args.setup = true; else if (arg === '--show') args.show = true; else if (arg === '--hide') args.hide = true; else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true; @@ -298,6 +301,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggle || args.toggleVisibleOverlay || args.settings || + args.setup || args.show || args.hide || args.showVisibleOverlay || @@ -341,6 +345,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggle || args.toggleVisibleOverlay || args.settings || + args.setup || args.copySubtitle || args.copySubtitleMultiple || args.mineSentence || @@ -371,6 +376,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.toggleVisibleOverlay && !args.show && !args.hide && + !args.setup && !args.showVisibleOverlay && !args.hideVisibleOverlay && !args.copySubtitle && diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 3d0bd0d..462707d 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -18,6 +18,7 @@ test('printHelp includes configured texthooker port', () => { assert.match(output, /--help\s+Show this help/); assert.match(output, /default: 7777/); assert.match(output, /--refresh-known-words/); + assert.match(output, /--setup\s+Open first-run setup window/); assert.match(output, /--anilist-status/); assert.match(output, /--anilist-retry-queue/); assert.match(output, /--dictionary/); diff --git a/src/cli/help.ts b/src/cli/help.ts index a2add68..3b92880 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -20,6 +20,7 @@ ${B}Overlay${R} --show-visible-overlay Show subtitle overlay --hide-visible-overlay Hide subtitle overlay --settings Open Yomitan settings window + --setup Open first-run setup window --auto-start-overlay Auto-hide mpv subs, show overlay on connect ${B}Mining${R} diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index ed38841..8a9cc0a 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -11,6 +11,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, settings: false, + setup: false, show: false, hide: false, showVisibleOverlay: false, diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 2be97e7..b996060 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -4,7 +4,8 @@ import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup'; function makeDeps(overrides: Partial = {}) { const calls: string[] = []; - const deps: AppReadyRuntimeDeps = { + const deps = { + ensureDefaultConfigBootstrap: () => calls.push('ensureDefaultConfigBootstrap'), loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), resolveKeybindings: () => calls.push('resolveKeybindings'), createMpvClient: () => calls.push('createMpvClient'), @@ -20,8 +21,13 @@ function makeDeps(overrides: Partial = {}) { setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`), defaultSecondarySubMode: 'hover', defaultWebsocketPort: 9001, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, hasMpvWebsocketPlugin: () => true, startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`), + startAnnotationWebsocket: (port) => calls.push(`startAnnotationWebsocket:${port}`), + startTexthooker: (port, websocketUrl) => + calls.push(`startTexthooker:${port}:${websocketUrl ?? ''}`), log: (message) => calls.push(`log:${message}`), createMecabTokenizerAndCheck: async () => { calls.push('createMecabTokenizerAndCheck'); @@ -34,6 +40,9 @@ function makeDeps(overrides: Partial = {}) { loadYomitanExtension: async () => { calls.push('loadYomitanExtension'); }, + handleFirstRunSetup: async () => { + calls.push('handleFirstRunSetup'); + }, prewarmSubtitleDictionaries: async () => { calls.push('prewarmSubtitleDictionaries'); }, @@ -48,7 +57,7 @@ function makeDeps(overrides: Partial = {}) { logDebug: (message) => calls.push(`debug:${message}`), now: () => 1000, ...overrides, - }; + } as AppReadyRuntimeDeps; return { deps, calls }; } @@ -57,7 +66,9 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy hasMpvWebsocketPlugin: () => false, }); await runAppReadyRuntime(deps); + assert.ok(calls.includes('ensureDefaultConfigBootstrap')); assert.ok(calls.includes('startSubtitleWebsocket:9001')); + assert.ok(calls.includes('startAnnotationWebsocket:6678')); assert.ok(calls.includes('setVisibleOverlayVisible:true')); assert.ok(calls.includes('initializeOverlayRuntime')); assert.ok( @@ -71,6 +82,47 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy ); }); +test('runAppReadyRuntime starts texthooker on startup when enabled in config', async () => { + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + texthooker: { launchAtStartup: true }, + }), + }); + + await runAppReadyRuntime(deps); + + assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678')); + assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); + assert.ok( + calls.indexOf('createMpvClient') < calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678'), + ); + assert.ok( + calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678') < + calls.indexOf('handleInitialArgs'), + ); +}); + +test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => { + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + annotationWebsocket: { enabled: true, port: 6678 }, + secondarySub: {}, + texthooker: { launchAtStartup: true }, + }), + hasMpvWebsocketPlugin: () => true, + }); + + await runAppReadyRuntime(deps); + + assert.equal(calls.includes('startSubtitleWebsocket:9001'), false); + assert.ok(calls.includes('startAnnotationWebsocket:6678')); + assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678')); + assert.ok(calls.includes('log:mpv_websocket detected, skipping built-in WebSocket server')); +}); + test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => { const { deps, calls } = makeDeps({ shouldSkipHeavyStartup: () => true, @@ -102,6 +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('getResolvedConfig'), false); assert.equal(calls.includes('getConfigWarnings'), false); @@ -116,7 +169,10 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns assert.equal(calls.includes('logConfigWarning'), false); assert.equal(calls.includes('handleInitialArgs'), true); 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('handleFirstRunSetup')); + assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); }); test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 931f063..9700a5d 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -11,6 +11,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, settings: false, + setup: false, show: false, hide: false, showVisibleOverlay: false, @@ -96,6 +97,9 @@ function createDeps(overrides: Partial = {}) { openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, + openFirstRunSetup: () => { + calls.push('openFirstRunSetup'); + }, setVisibleOverlayVisible: (visible) => { calls.push(`setVisibleOverlayVisible:${visible}`); }, @@ -229,6 +233,16 @@ test('handleCliCommand processes --start for second-instance when overlay runtim ); }); +test('handleCliCommand opens first-run setup window for --setup', () => { + const { deps, calls } = createDeps(); + + handleCliCommand(makeArgs({ setup: true }), 'initial', deps); + + assert.ok(calls.includes('openFirstRunSetup')); + assert.ok(calls.includes('log:Opened first-run setup flow.')); + assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false); +}); + test('handleCliCommand applies cli log level for second-instance commands', () => { const { deps, calls } = createDeps({ setLogLevel: (level) => { diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index c205bc2..582896e 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -17,6 +17,7 @@ export interface CliCommandServiceDeps { isOverlayRuntimeInitialized: () => boolean; initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + openFirstRunSetup: () => void; openYomitanSettingsDelayed: (delayMs: number) => void; setVisibleOverlayVisible: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -115,6 +116,7 @@ interface MiningCliRuntime { } interface UiCliRuntime { + openFirstRunSetup: () => void; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -195,6 +197,7 @@ export function createCliCommandDepsRuntime( isOverlayRuntimeInitialized: options.overlay.isInitialized, initializeOverlayRuntime: options.overlay.initialize, toggleVisibleOverlay: options.overlay.toggleVisible, + openFirstRunSetup: options.ui.openFirstRunSetup, openYomitanSettingsDelayed: (delayMs) => { options.schedule(() => { options.ui.openYomitanSettings(); @@ -298,6 +301,9 @@ export function handleCliCommand( if (args.toggle || args.toggleVisibleOverlay) { deps.toggleVisibleOverlay(); + } else if (args.setup) { + deps.openFirstRunSetup(); + deps.log('Opened first-run setup flow.'); } else if (args.settings) { deps.openYomitanSettingsDelayed(1000); } else if (args.show || args.showVisibleOverlay) { diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index bd7301a..f500060 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -11,6 +11,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, settings: false, + setup: false, show: false, hide: false, showVisibleOverlay: false, diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index a254a85..e3a1771 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -69,6 +69,13 @@ export function runStartupBootstrapRuntime( } interface AppReadyConfigLike { + annotationWebsocket?: { + enabled?: boolean; + port?: number; + }; + texthooker?: { + launchAtStartup?: boolean; + }; secondarySub?: { defaultMode?: SecondarySubMode; }; @@ -92,6 +99,7 @@ interface AppReadyConfigLike { } export interface AppReadyRuntimeDeps { + ensureDefaultConfigBootstrap: () => void; loadSubtitlePosition: () => void; resolveKeybindings: () => void; createMpvClient: () => void; @@ -104,14 +112,19 @@ export interface AppReadyRuntimeDeps { setSecondarySubMode: (mode: SecondarySubMode) => void; defaultSecondarySubMode: SecondarySubMode; defaultWebsocketPort: number; + defaultAnnotationWebsocketPort: number; + defaultTexthookerPort: number; hasMpvWebsocketPlugin: () => boolean; startSubtitleWebsocket: (port: number) => void; + startAnnotationWebsocket: (port: number) => void; + startTexthooker: (port: number, websocketUrl?: string) => void; log: (message: string) => void; createMecabTokenizerAndCheck: () => Promise; createSubtitleTimingTracker: () => void; createImmersionTracker?: () => void; startJellyfinRemoteSession?: () => Promise; loadYomitanExtension: () => Promise; + handleFirstRunSetup: () => Promise; prewarmSubtitleDictionaries?: () => Promise; startBackgroundWarmups: () => void; texthookerOnlyMode: boolean; @@ -169,8 +182,10 @@ export function isAutoUpdateEnabledRuntime( export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { const now = deps.now ?? (() => Date.now()); const startupStartedAtMs = now(); + deps.ensureDefaultConfigBootstrap(); if (deps.shouldSkipHeavyStartup?.()) { await deps.loadYomitanExtension(); + await deps.handleFirstRunSetup(); deps.handleInitialArgs(); return; } @@ -179,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { logger.warn(`[AniList] ${message}`); @@ -601,6 +622,41 @@ const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); +let firstRunSetupMessage: string | null = null; +const firstRunSetupService = createFirstRunSetupService({ + configDir: CONFIG_DIR, + getYomitanDictionaryCount: async () => { + await ensureYomitanExtensionLoaded(); + const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + return dictionaries.length; + }, + detectPluginInstalled: () => { + const installPaths = resolveDefaultMpvInstallPaths( + process.platform, + os.homedir(), + process.env.XDG_CONFIG_HOME, + ); + return detectInstalledFirstRunPlugin(installPaths); + }, + installPlugin: async () => + installFirstRunPluginToDefaultLocation({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + }), + onStateChanged: (state) => { + appState.firstRunSetupCompleted = state.status === 'completed'; + if (appTray) { + ensureTray(); + } + }, +}); const discordPresenceSessionStartedAtMs = Date.now(); let discordPresenceMediaDurationSec: number | null = null; @@ -890,6 +946,11 @@ const buildSubtitleProcessingControllerMainDepsHandler = topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); + annotationSubtitleWsService.broadcast(payload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); }, logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); @@ -1596,6 +1657,96 @@ const { }, }); +const maybeFocusExistingFirstRunSetupWindow = + createMaybeFocusExistingFirstRunSetupWindowHandler({ + getSetupWindow: () => appState.firstRunSetupWindow, + }); +const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ + maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow, + createSetupWindow: () => + new BrowserWindow({ + width: 480, + height: 460, + title: 'SubMiner Setup', + show: true, + autoHideMenuBar: true, + resizable: false, + minimizable: false, + maximizable: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }), + getSetupSnapshot: async () => { + const snapshot = await firstRunSetupService.getSetupStatus(); + return { + configReady: snapshot.configReady, + dictionaryCount: snapshot.dictionaryCount, + canFinish: snapshot.canFinish, + pluginStatus: snapshot.pluginStatus, + pluginInstallPathSummary: snapshot.pluginInstallPathSummary, + message: firstRunSetupMessage, + }; + }, + buildSetupHtml: (model) => buildFirstRunSetupHtml(model), + parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), + handleAction: async (action: FirstRunSetupAction) => { + if (action === 'install-plugin') { + const snapshot = await firstRunSetupService.installMpvPlugin(); + firstRunSetupMessage = snapshot.message; + return; + } + if (action === 'open-yomitan-settings') { + openYomitanSettings(); + firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.'; + return; + } + if (action === 'refresh') { + const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); + firstRunSetupMessage = snapshot.message; + return; + } + if (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.'; + return; + }, + markSetupInProgress: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupInProgress(); + }, + markSetupCancelled: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupCancelled(); + }, + isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + clearSetupWindow: () => { + appState.firstRunSetupWindow = null; + }, + setSetupWindow: (window) => { + appState.firstRunSetupWindow = window as BrowserWindow; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logError: (message, error) => logger.error(message, error), +}); + +function openFirstRunSetupWindow(): void { + if (firstRunSetupService.isSetupCompleted()) { + return; + } + openFirstRunSetupWindowHandler(); +} + const { notifyAnilistSetup, consumeAnilistSetupTokenFromUrl, @@ -2018,7 +2169,10 @@ const { restoreOverlayMpvSubtitles(); }, unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), - stopSubtitleWebsocket: () => subtitleWsService.stop(), + stopSubtitleWebsocket: () => { + subtitleWsService.stop(); + annotationSubtitleWsService.stop(); + }, stopTexthookerService: () => texthookerService.stop(), getYomitanParserWindow: () => appState.yomitanParserWindow, clearYomitanParserState: () => { @@ -2123,6 +2277,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ }, }, appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => { + ensureDefaultConfigBootstrap({ + configDir: CONFIG_DIR, + configFilePaths: getDefaultConfigFilePaths(CONFIG_DIR), + generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG), + }); + }, loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); @@ -2157,9 +2318,49 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ }, defaultSecondarySubMode: 'hover', defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, + defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port, + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), startSubtitleWebsocket: (port: number) => { - subtitleWsService.start(port, () => appState.currentSubText); + subtitleWsService.start( + port, + () => + appState.currentSubtitleData ?? + (appState.currentSubText + ? { + text: appState.currentSubText, + tokens: null, + } + : null), + () => ({ + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }), + ); + }, + startAnnotationWebsocket: (port: number) => { + annotationSubtitleWsService.start( + port, + () => + appState.currentSubtitleData ?? + (appState.currentSubText + ? { + text: appState.currentSubText, + tokens: null, + } + : null), + () => ({ + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }), + ); + }, + startTexthooker: (port: number, websocketUrl?: string) => { + if (!texthookerService.isRunning()) { + texthookerService.start(port, websocketUrl); + } }, log: (message) => appLogger.logInfo(message), createMecabTokenizerAndCheck: async () => { @@ -2172,6 +2373,17 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ loadYomitanExtension: async () => { await loadYomitanExtension(); }, + handleFirstRunSetup: async () => { + const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); + appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; + if ( + appState.initialArgs && + shouldAutoOpenFirstRunSetup(appState.initialArgs) && + snapshot.state.status !== 'completed' + ) { + openFirstRunSetupWindow(); + } + }, startJellyfinRemoteSession: async () => { await startJellyfinRemoteSession(); }, @@ -2192,7 +2404,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ shouldSkipHeavyStartup: () => Boolean( appState.initialArgs && - (shouldRunSettingsOnlyStartup(appState.initialArgs) || appState.initialArgs.dictionary), + (shouldRunSettingsOnlyStartup(appState.initialArgs) || + appState.initialArgs.dictionary || + appState.initialArgs.setup), ), createImmersionTracker: () => { ensureImmersionTrackerStarted(); @@ -3092,6 +3306,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ showMpvOsd: (text: string) => showMpvOsd(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), + openFirstRunSetupWindow: () => openFirstRunSetupWindow(), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), @@ -3163,6 +3378,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = initializeOverlayRuntime: () => initializeOverlayRuntime(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), + openFirstRunSetupWindow: () => openFirstRunSetupWindow(), openYomitanSettings: () => openYomitanSettings(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(), diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 6fa40c4..91edb71 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -19,6 +19,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput { } export interface AppReadyRuntimeDepsFactoryInput { + ensureDefaultConfigBootstrap: AppReadyRuntimeDeps['ensureDefaultConfigBootstrap']; loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition']; resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings']; createMpvClient: AppReadyRuntimeDeps['createMpvClient']; @@ -30,8 +31,12 @@ export interface AppReadyRuntimeDepsFactoryInput { setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode']; defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode']; defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort']; + defaultAnnotationWebsocketPort: AppReadyRuntimeDeps['defaultAnnotationWebsocketPort']; + defaultTexthookerPort: AppReadyRuntimeDeps['defaultTexthookerPort']; hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin']; startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket']; + startAnnotationWebsocket: AppReadyRuntimeDeps['startAnnotationWebsocket']; + startTexthooker: AppReadyRuntimeDeps['startTexthooker']; log: AppReadyRuntimeDeps['log']; setLogLevel: AppReadyRuntimeDeps['setLogLevel']; createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck']; @@ -39,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput { createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; + handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup']; prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; @@ -75,6 +81,7 @@ export function createAppReadyRuntimeDeps( params: AppReadyRuntimeDepsFactoryInput, ): AppReadyRuntimeDeps { return { + ensureDefaultConfigBootstrap: params.ensureDefaultConfigBootstrap, loadSubtitlePosition: params.loadSubtitlePosition, resolveKeybindings: params.resolveKeybindings, createMpvClient: params.createMpvClient, @@ -86,8 +93,12 @@ export function createAppReadyRuntimeDeps( setSecondarySubMode: params.setSecondarySubMode, defaultSecondarySubMode: params.defaultSecondarySubMode, defaultWebsocketPort: params.defaultWebsocketPort, + defaultAnnotationWebsocketPort: params.defaultAnnotationWebsocketPort, + defaultTexthookerPort: params.defaultTexthookerPort, hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin, startSubtitleWebsocket: params.startSubtitleWebsocket, + startAnnotationWebsocket: params.startAnnotationWebsocket, + startTexthooker: params.startTexthooker, log: params.log, setLogLevel: params.setLogLevel, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, @@ -95,6 +106,7 @@ export function createAppReadyRuntimeDeps( createImmersionTracker: params.createImmersionTracker, startJellyfinRemoteSession: params.startJellyfinRemoteSession, loadYomitanExtension: params.loadYomitanExtension, + handleFirstRunSetup: params.handleFirstRunSetup, prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, startBackgroundWarmups: params.startBackgroundWarmups, texthookerOnlyMode: params.texthookerOnlyMode, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 27f3af0..887cea2 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -18,6 +18,7 @@ export interface CliCommandRuntimeServiceContext { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -103,6 +104,7 @@ function createCliCommandDepsFromContext( runCommand: context.runJellyfinCommand, }, ui: { + openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, cycleSecondarySubMode: context.cycleSecondarySubMode, openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 5c7acb4..b5594c7 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -159,6 +159,7 @@ export interface CliCommandRuntimeServiceDepsParams { runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; }; ui: { + openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; @@ -307,6 +308,7 @@ export function createCliCommandRuntimeServiceDeps( runCommand: params.jellyfin.runCommand, }, ui: { + openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, cycleSecondarySubMode: params.ui.cycleSecondarySubMode, openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts index b941d3e..c3fd98c 100644 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -5,6 +5,7 @@ import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps test('app-ready main deps builder returns mapped app-ready runtime deps', async () => { const calls: string[] = []; const onReady = createBuildAppReadyRuntimeMainDepsHandler({ + ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'), loadSubtitlePosition: () => calls.push('load-subtitle-position'), resolveKeybindings: () => calls.push('resolve-keybindings'), createMpvClient: () => calls.push('create-mpv-client'), @@ -16,8 +17,12 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async setSecondarySubMode: () => calls.push('set-secondary-sub-mode'), defaultSecondarySubMode: 'hover', defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, hasMpvWebsocketPlugin: () => false, startSubtitleWebsocket: () => calls.push('start-ws'), + startAnnotationWebsocket: () => calls.push('start-annotation-ws'), + startTexthooker: () => calls.push('start-texthooker'), log: () => calls.push('log'), setLogLevel: () => calls.push('set-log-level'), createMecabTokenizerAndCheck: async () => { @@ -31,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async loadYomitanExtension: async () => { calls.push('load-yomitan'); }, + handleFirstRunSetup: async () => { + calls.push('handle-first-run-setup'); + }, prewarmSubtitleDictionaries: async () => { calls.push('prewarm-dicts'); }, @@ -49,6 +57,8 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async assert.equal(onReady.defaultSecondarySubMode, 'hover'); assert.equal(onReady.defaultWebsocketPort, 5174); + assert.equal(onReady.defaultAnnotationWebsocketPort, 6678); + assert.equal(onReady.defaultTexthookerPort, 5174); assert.equal(onReady.texthookerOnlyMode, false); assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); assert.equal(onReady.now?.(), 123); @@ -57,8 +67,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async onReady.createMpvClient(); await onReady.createMecabTokenizerAndCheck(); await onReady.loadYomitanExtension(); + await onReady.handleFirstRunSetup(); await onReady.prewarmSubtitleDictionaries?.(); onReady.startBackgroundWarmups(); + onReady.startTexthooker(5174); onReady.setVisibleOverlayVisible(true); assert.deepEqual(calls, [ @@ -67,8 +79,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async 'create-mpv-client', 'create-mecab', 'load-yomitan', + 'handle-first-run-setup', 'prewarm-dicts', 'start-warmups', + 'start-texthooker', 'set-visible-overlay', ]); }); diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index d658f09..435afc2 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -2,6 +2,7 @@ import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle'; export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeDepsFactoryInput) { return (): AppReadyRuntimeDepsFactoryInput => ({ + ensureDefaultConfigBootstrap: deps.ensureDefaultConfigBootstrap, loadSubtitlePosition: deps.loadSubtitlePosition, resolveKeybindings: deps.resolveKeybindings, createMpvClient: deps.createMpvClient, @@ -13,8 +14,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD setSecondarySubMode: deps.setSecondarySubMode, defaultSecondarySubMode: deps.defaultSecondarySubMode, defaultWebsocketPort: deps.defaultWebsocketPort, + defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort, + defaultTexthookerPort: deps.defaultTexthookerPort, hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin, startSubtitleWebsocket: deps.startSubtitleWebsocket, + startAnnotationWebsocket: deps.startAnnotationWebsocket, + startTexthooker: deps.startTexthooker, log: deps.log, setLogLevel: deps.setLogLevel, createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck, @@ -22,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD createImmersionTracker: deps.createImmersionTracker, startJellyfinRemoteSession: deps.startJellyfinRemoteSession, loadYomitanExtension: deps.loadYomitanExtension, + handleFirstRunSetup: deps.handleFirstRunSetup, prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, startBackgroundWarmups: deps.startBackgroundWarmups, texthookerOnlyMode: deps.texthookerOnlyMode, diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 13f08a8..aa4099d 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -18,6 +18,7 @@ test('build cli command context deps maps handlers and values', () => { isOverlayInitialized: () => true, initializeOverlay: () => calls.push('init'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + openFirstRunSetup: () => calls.push('setup'), setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy'), startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 3e2cf7f..f5476d4 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -16,6 +16,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -61,6 +62,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { isOverlayInitialized: deps.isOverlayInitialized, initializeOverlay: deps.initializeOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay, + openFirstRunSetup: deps.openFirstRunSetup, setVisibleOverlay: deps.setVisibleOverlay, copyCurrentSubtitle: deps.copyCurrentSubtitle, startPendingMultiCopy: deps.startPendingMultiCopy, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 850cbce..005fd28 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -20,6 +20,7 @@ test('cli command context factory composes main deps and context handlers', () = showMpvOsd: (text) => calls.push(`osd:${text}`), initializeOverlayRuntime: () => calls.push('init-overlay'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + openFirstRunSetupWindow: () => calls.push('setup'), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy-sub'), startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index eebc12a..6e77b81 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -23,6 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async () initializeOverlayRuntime: () => calls.push('init-overlay'), toggleVisibleOverlay: () => calls.push('toggle-visible'), + openFirstRunSetupWindow: () => calls.push('open-setup'), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy-sub'), @@ -107,10 +108,11 @@ test('cli command context main deps builder maps state and callbacks', async () assert.equal(deps.shouldOpenBrowser(), true); deps.showOsd('hello'); deps.initializeOverlay(); + deps.openFirstRunSetup(); deps.setVisibleOverlay(true); deps.printHelp(); - assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']); + assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']); const retry = await deps.retryAnilistQueueNow(); assert.deepEqual(retry, { ok: true, message: 'ok' }); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 5a02807..da6d7f5 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -19,6 +19,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + openFirstRunSetupWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -71,6 +72,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, initializeOverlay: () => deps.initializeOverlayRuntime(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + openFirstRunSetup: () => deps.openFirstRunSetupWindow(), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index f4e32d2..dfae787 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -24,6 +24,7 @@ function createDeps() { isOverlayInitialized: () => true, initializeOverlay: () => {}, toggleVisibleOverlay: () => {}, + openFirstRunSetup: () => {}, setVisibleOverlay: () => {}, copyCurrentSubtitle: () => {}, startPendingMultiCopy: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 11298f8..25df822 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -21,6 +21,7 @@ export type CliCommandContextFactoryDeps = { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -73,6 +74,7 @@ export function createCliCommandContext( isOverlayInitialized: deps.isOverlayInitialized, initializeOverlay: deps.initializeOverlay, toggleVisibleOverlay: deps.toggleVisibleOverlay, + openFirstRunSetup: deps.openFirstRunSetup, setVisibleOverlay: deps.setVisibleOverlay, copyCurrentSubtitle: deps.copyCurrentSubtitle, startPendingMultiCopy: deps.startPendingMultiCopy, diff --git a/src/main/runtime/composers/app-ready-composer.test.ts b/src/main/runtime/composers/app-ready-composer.test.ts index fd1f582..11b8641 100644 --- a/src/main/runtime/composers/app-ready-composer.test.ts +++ b/src/main/runtime/composers/app-ready-composer.test.ts @@ -26,6 +26,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => }, }, appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => {}, loadSubtitlePosition: () => {}, resolveKeybindings: () => {}, createMpvClient: () => {}, @@ -37,12 +38,17 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => setSecondarySubMode: () => {}, defaultSecondarySubMode: 'hover', defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, hasMpvWebsocketPlugin: () => false, startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, log: () => {}, createMecabTokenizerAndCheck: async () => {}, createSubtitleTimingTracker: () => {}, loadYomitanExtension: async () => {}, + handleFirstRunSetup: async () => {}, startJellyfinRemoteSession: async () => {}, prewarmSubtitleDictionaries: async () => {}, startBackgroundWarmups: () => {}, diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts new file mode 100644 index 0000000..b2e13f6 --- /dev/null +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + detectInstalledFirstRunPlugin, + installFirstRunPluginToDefaultLocation, + resolvePackagedFirstRunPluginAssets, +} from './first-run-setup-plugin'; +import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state'; + +function withTempDir(fn: (dir: string) => Promise | void): Promise | void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-plugin-test-')); + const result = fn(dir); + if (result instanceof Promise) { + return result.finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + } + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => { + withTempDir((root) => { + const resourcesPath = path.join(root, 'resources'); + const pluginRoot = path.join(resourcesPath, 'plugin'); + fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- plugin'); + fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n'); + + const resolved = resolvePackagedFirstRunPluginAssets({ + dirname: path.join(root, 'dist', 'main', 'runtime'), + appPath: path.join(root, 'app'), + resourcesPath, + }); + + assert.deepEqual(resolved, { + pluginDirSource: path.join(pluginRoot, 'subminer'), + pluginConfigSource: path.join(pluginRoot, 'subminer.conf'), + }); + }); +}); + +test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => { + withTempDir((root) => { + const resourcesPath = path.join(root, 'resources'); + const pluginRoot = path.join(resourcesPath, 'plugin'); + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome); + + fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin'); + fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n'); + + fs.mkdirSync(installPaths.pluginDir, { recursive: true }); + fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true }); + fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin'); + fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n'); + + const result = installFirstRunPluginToDefaultLocation({ + platform: 'linux', + homeDir, + xdgConfigHome, + dirname: path.join(root, 'dist', 'main', 'runtime'), + appPath: path.join(root, 'app'), + resourcesPath, + }); + + assert.equal(result.ok, true); + assert.equal(result.pluginInstallStatus, 'installed'); + assert.equal(detectInstalledFirstRunPlugin(installPaths), true); + assert.equal( + fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'), + '-- packaged plugin', + ); + assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n'); + + const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir); + const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir); + assert.equal(scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')), true); + assert.equal( + scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')), + true, + ); + }); +}); + +test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => { + const result = installFirstRunPluginToDefaultLocation({ + platform: 'win32', + homeDir: '/tmp/home', + xdgConfigHome: '/tmp/xdg', + dirname: '/tmp/dist/main/runtime', + appPath: '/tmp/app', + resourcesPath: '/tmp/resources', + }); + + assert.equal(result.ok, false); + assert.equal(result.pluginInstallStatus, 'failed'); + assert.match(result.message, /not supported/i); +}); diff --git a/src/main/runtime/first-run-setup-plugin.ts b/src/main/runtime/first-run-setup-plugin.ts new file mode 100644 index 0000000..352f0d6 --- /dev/null +++ b/src/main/runtime/first-run-setup-plugin.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state'; +import type { PluginInstallResult } from './first-run-setup-service'; + +function timestamp(): string { + return new Date().toISOString().replaceAll(':', '-'); +} + +function backupExistingPath(targetPath: string): void { + if (!fs.existsSync(targetPath)) return; + fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`); +} + +export function resolvePackagedFirstRunPluginAssets(deps: { + dirname: string; + appPath: string; + resourcesPath: string; + joinPath?: (...parts: string[]) => string; + existsSync?: (candidate: string) => boolean; +}): { pluginDirSource: string; pluginConfigSource: string } | null { + const joinPath = deps.joinPath ?? path.join; + const existsSync = deps.existsSync ?? fs.existsSync; + const roots = [ + joinPath(deps.resourcesPath, 'plugin'), + joinPath(deps.resourcesPath, 'app.asar', 'plugin'), + joinPath(deps.appPath, 'plugin'), + joinPath(deps.dirname, '..', 'plugin'), + joinPath(deps.dirname, '..', '..', 'plugin'), + ]; + + for (const root of roots) { + const pluginDirSource = joinPath(root, 'subminer'); + const pluginConfigSource = joinPath(root, 'subminer.conf'); + if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) { + return { pluginDirSource, pluginConfigSource }; + } + } + + return null; +} + +export function detectInstalledFirstRunPlugin( + installPaths: MpvInstallPaths, + deps?: { existsSync?: (candidate: string) => boolean }, +): boolean { + const existsSync = deps?.existsSync ?? fs.existsSync; + return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath); +} + +export function installFirstRunPluginToDefaultLocation(options: { + platform: NodeJS.Platform; + homeDir: string; + xdgConfigHome?: string; + dirname: string; + appPath: string; + resourcesPath: string; +}): PluginInstallResult { + const installPaths = resolveDefaultMpvInstallPaths( + options.platform, + options.homeDir, + options.xdgConfigHome, + ); + if (!installPaths.supported) { + return { + ok: false, + pluginInstallStatus: 'failed', + pluginInstallPathSummary: installPaths.mpvConfigDir, + message: 'Automatic mpv plugin install is not supported on this platform yet.', + }; + } + + const assets = resolvePackagedFirstRunPluginAssets({ + dirname: options.dirname, + appPath: options.appPath, + resourcesPath: options.resourcesPath, + }); + if (!assets) { + return { + ok: false, + pluginInstallStatus: 'failed', + pluginInstallPathSummary: installPaths.mpvConfigDir, + message: 'Packaged mpv plugin assets were not found.', + }; + } + + fs.mkdirSync(installPaths.scriptsDir, { recursive: true }); + fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true }); + backupExistingPath(installPaths.pluginDir); + backupExistingPath(installPaths.pluginConfigPath); + fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true }); + fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath); + + return { + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: installPaths.mpvConfigDir, + message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`, + }; +} diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts new file mode 100644 index 0000000..efe8f0c --- /dev/null +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + createFirstRunSetupService, + shouldAutoOpenFirstRunSetup, +} from './first-run-setup-service'; +import type { CliArgs } from '../../cli/args'; + +function withTempDir(fn: (dir: string) => Promise | void): Promise | void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-')); + const result = fn(dir); + if (result instanceof Promise) { + return result.finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + } + fs.rmSync(dir, { recursive: true, force: true }); +} + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + background: false, + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + settings: false, + setup: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + refreshKnownWords: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, + dictionary: false, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinSubtitleUrlsOnly: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + jellyfinPreviewAuth: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + debug: false, + ...overrides, + }; +} + +test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => { + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true); + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })), + false, + ); + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false); +}); + +test('setup service auto-completes legacy installs with config and 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 () => 2, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + message: 'installed', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await service.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(snapshot.state.completionSource, 'legacy_auto_detected'); + assert.equal(snapshot.dictionaryCount, 2); + assert.equal(snapshot.canFinish, true); + }); +}); + +test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', 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; + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => dictionaryCount, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + message: 'installed', + }), + onStateChanged: () => undefined, + }); + + const initial = await service.ensureSetupStateInitialized(); + 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'); + + dictionaryCount = 1; + const refreshed = await service.refreshStatus(); + assert.equal(refreshed.canFinish, true); + + const completed = await service.markSetupCompleted(); + assert.equal(completed.state.status, 'completed'); + assert.equal(completed.state.completionSource, 'user'); + }); +}); + +test('setup service marks cancelled when popup closes before completion', 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, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + await service.ensureSetupStateInitialized(); + await service.markSetupInProgress(); + const cancelled = await service.markSetupCancelled(); + assert.equal(cancelled.state.status, 'cancelled'); + }); +}); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts new file mode 100644 index 0000000..0ddf968 --- /dev/null +++ b/src/main/runtime/first-run-setup-service.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs'; +import { + createDefaultSetupState, + getDefaultConfigFilePaths, + getSetupStatePath, + isSetupCompleted, + readSetupState, + writeSetupState, + type SetupPluginInstallStatus, + type SetupState, +} from '../../shared/setup-state'; +import type { CliArgs } from '../../cli/args'; + +export interface SetupStatusSnapshot { + configReady: boolean; + dictionaryCount: number; + canFinish: boolean; + pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; + pluginInstallPathSummary: string | null; + message: string | null; + state: SetupState; +} + +export interface PluginInstallResult { + ok: boolean; + pluginInstallStatus: SetupPluginInstallStatus; + pluginInstallPathSummary: string | null; + message: string; +} + +export interface FirstRunSetupService { + ensureSetupStateInitialized: () => Promise; + getSetupStatus: () => Promise; + refreshStatus: (message?: string | null) => Promise; + markSetupInProgress: () => Promise; + markSetupCancelled: () => Promise; + markSetupCompleted: () => Promise; + skipPluginInstall: () => Promise; + installMpvPlugin: () => Promise; + isSetupCompleted: () => boolean; +} + +function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { + return Boolean( + args.toggle || + args.toggleVisibleOverlay || + args.settings || + args.show || + args.hide || + args.showVisibleOverlay || + args.hideVisibleOverlay || + args.copySubtitle || + args.copySubtitleMultiple || + args.mineSentence || + args.mineSentenceMultiple || + args.updateLastCardFromClipboard || + args.refreshKnownWords || + args.toggleSecondarySub || + args.triggerFieldGrouping || + args.triggerSubsync || + args.markAudioCard || + args.openRuntimeOptions || + args.anilistStatus || + args.anilistLogout || + args.anilistSetup || + args.anilistRetryQueue || + args.dictionary || + args.jellyfin || + args.jellyfinLogin || + args.jellyfinLogout || + args.jellyfinLibraries || + args.jellyfinItems || + args.jellyfinSubtitles || + args.jellyfinPlay || + args.jellyfinRemoteAnnounce || + args.jellyfinPreviewAuth || + args.texthooker || + args.help + ); +} + +export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean { + if (args.setup) return true; + if (!args.start && !args.background) return false; + return !hasAnyStartupCommandBeyondSetup(args); +} + +function getPluginStatus(state: SetupState, pluginInstalled: boolean): SetupStatusSnapshot['pluginStatus'] { + if (pluginInstalled) return 'installed'; + if (state.pluginInstallStatus === 'skipped') return 'skipped'; + if (state.pluginInstallStatus === 'failed') return 'failed'; + return 'optional'; +} + +export function createFirstRunSetupService(deps: { + configDir: string; + getYomitanDictionaryCount: () => Promise; + detectPluginInstalled: () => boolean | Promise; + installPlugin: () => Promise; + onStateChanged?: (state: SetupState) => void; +}): FirstRunSetupService { + const setupStatePath = getSetupStatePath(deps.configDir); + const configFilePaths = getDefaultConfigFilePaths(deps.configDir); + let completed = false; + + const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState(); + const writeState = (state: SetupState): SetupState => { + writeSetupState(setupStatePath, state); + completed = state.status === 'completed'; + deps.onStateChanged?.(state); + return state; + }; + + const buildSnapshot = async (state: SetupState, message: string | null = null) => { + const dictionaryCount = await deps.getYomitanDictionaryCount(); + const pluginInstalled = await deps.detectPluginInstalled(); + const configReady = + fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); + return { + configReady, + dictionaryCount, + canFinish: dictionaryCount >= 1, + pluginStatus: getPluginStatus(state, pluginInstalled), + pluginInstallPathSummary: state.pluginInstallPathSummary, + message, + state, + } satisfies SetupStatusSnapshot; + }; + + const refreshWithState = async (state: SetupState, message: string | null = null) => { + const snapshot = await buildSnapshot(state, message); + if (snapshot.state.lastSeenYomitanDictionaryCount !== snapshot.dictionaryCount) { + snapshot.state = writeState({ + ...snapshot.state, + lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, + }); + } + return snapshot; + }; + + return { + ensureSetupStateInitialized: async () => { + const state = readState(); + if (isSetupCompleted(state)) { + completed = true; + return refreshWithState(state); + } + + const dictionaryCount = await deps.getYomitanDictionaryCount(); + const configReady = + fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); + if (configReady && dictionaryCount >= 1) { + const completedState = writeState({ + ...state, + status: 'completed', + completedAt: new Date().toISOString(), + completionSource: 'legacy_auto_detected', + lastSeenYomitanDictionaryCount: dictionaryCount, + }); + return buildSnapshot(completedState); + } + + return refreshWithState( + writeState({ + ...state, + status: state.status === 'cancelled' ? 'cancelled' : 'incomplete', + completedAt: null, + completionSource: null, + lastSeenYomitanDictionaryCount: dictionaryCount, + }), + ); + }, + getSetupStatus: async () => refreshWithState(readState()), + refreshStatus: async (message = null) => refreshWithState(readState(), message), + markSetupInProgress: async () => { + const state = readState(); + if (state.status === 'completed') { + completed = true; + return refreshWithState(state); + } + return refreshWithState(writeState({ ...state, status: 'in_progress' })); + }, + markSetupCancelled: async () => { + const state = readState(); + if (state.status === 'completed') { + completed = true; + return refreshWithState(state); + } + return refreshWithState(writeState({ ...state, status: 'cancelled' })); + }, + markSetupCompleted: async () => { + const state = readState(); + const snapshot = await buildSnapshot(state); + if (!snapshot.canFinish) { + return snapshot; + } + return refreshWithState( + writeState({ + ...state, + status: 'completed', + completedAt: new Date().toISOString(), + completionSource: 'user', + lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, + }), + ); + }, + skipPluginInstall: async () => + refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })), + installMpvPlugin: async () => { + const result = await deps.installPlugin(); + return refreshWithState( + writeState({ + ...readState(), + pluginInstallStatus: result.pluginInstallStatus, + pluginInstallPathSummary: result.pluginInstallPathSummary, + }), + result.message, + ); + }, + isSetupCompleted: () => completed || isSetupCompleted(readState()), + }; +} diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts new file mode 100644 index 0000000..d895847 --- /dev/null +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildFirstRunSetupHtml, + createHandleFirstRunSetupNavigationHandler, + createMaybeFocusExistingFirstRunSetupWindowHandler, + parseFirstRunSetupSubmissionUrl, +} from './first-run-setup-window'; + +test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => { + const html = buildFirstRunSetupHtml({ + configReady: true, + dictionaryCount: 0, + canFinish: false, + pluginStatus: 'optional', + pluginInstallPathSummary: null, + message: 'Waiting for dictionaries', + }); + + assert.match(html, /SubMiner setup/); + assert.match(html, /Install mpv plugin/); + assert.match(html, /Open Yomitan Settings/); + assert.match(html, /Finish setup/); + assert.match(html, /disabled/); +}); + +test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => { + const html = buildFirstRunSetupHtml({ + configReady: true, + dictionaryCount: 1, + canFinish: true, + pluginStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + message: null, + }); + + assert.match(html, /Reinstall mpv plugin/); +}); + +test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { + assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { + action: 'refresh', + }); + assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null); +}); + +test('first-run setup window handler focuses existing window', () => { + const calls: string[] = []; + const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({ + getSetupWindow: () => ({ + focus: () => calls.push('focus'), + }), + }); + + assert.equal(maybeFocus(), true); + assert.deepEqual(calls, ['focus']); +}); + +test('first-run setup navigation handler prevents default and dispatches action', async () => { + const calls: string[] = []; + const handleNavigation = createHandleFirstRunSetupNavigationHandler({ + parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url), + handleAction: async (action) => { + calls.push(action); + }, + logError: (message) => calls.push(message), + }); + + const prevented = handleNavigation({ + url: 'subminer://first-run-setup?action=install-plugin', + preventDefault: () => calls.push('preventDefault'), + }); + + assert.equal(prevented, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['preventDefault', 'install-plugin']); +}); diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts new file mode 100644 index 0000000..227e47d --- /dev/null +++ b/src/main/runtime/first-run-setup-window.ts @@ -0,0 +1,329 @@ +type FocusableWindowLike = { + focus: () => void; +}; + +type FirstRunSetupWebContentsLike = { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; +}; + +type FirstRunSetupWindowLike = FocusableWindowLike & { + webContents: FirstRunSetupWebContentsLike; + loadURL: (url: string) => unknown; + on: (event: 'closed', handler: () => void) => void; + isDestroyed: () => boolean; + close: () => void; +}; + +export type FirstRunSetupAction = + | 'install-plugin' + | 'open-yomitan-settings' + | 'refresh' + | 'skip-plugin' + | 'finish'; + +export interface FirstRunSetupHtmlModel { + configReady: boolean; + dictionaryCount: number; + canFinish: boolean; + pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; + pluginInstallPathSummary: string | null; + message: string | null; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'danger'): string { + return `${escapeHtml(value)}`; +} + +export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { + const pluginActionLabel = + model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin'; + const pluginLabel = + model.pluginStatus === 'installed' + ? 'Installed' + : model.pluginStatus === 'skipped' + ? 'Skipped' + : model.pluginStatus === 'failed' + ? 'Failed' + : 'Optional'; + const pluginTone = + model.pluginStatus === 'installed' + ? 'ready' + : model.pluginStatus === 'failed' + ? 'danger' + : model.pluginStatus === 'skipped' + ? 'muted' + : 'warn'; + + return ` + + + + SubMiner First-Run Setup + + + +
+

SubMiner setup

+
+
+ Config file +
Default config directory seeded automatically.
+
+ ${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')} +
+
+
+ mpv plugin +
${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}
+
+ ${renderStatusBadge(pluginLabel, pluginTone)} +
+
+
+ Yomitan dictionaries +
${model.dictionaryCount} installed
+
+ ${renderStatusBadge( + model.dictionaryCount >= 1 ? 'Ready' : 'Missing', + model.dictionaryCount >= 1 ? 'ready' : 'warn', + )} +
+
+ + + + + +
+
${model.message ? escapeHtml(model.message) : ''}
+ +
+ +`; +} + +export function parseFirstRunSetupSubmissionUrl( + rawUrl: string, +): { action: FirstRunSetupAction } | null { + if (!rawUrl.startsWith('subminer://first-run-setup')) { + return null; + } + const parsed = new URL(rawUrl); + const action = parsed.searchParams.get('action'); + if ( + action !== 'install-plugin' && + action !== 'open-yomitan-settings' && + action !== 'refresh' && + action !== 'skip-plugin' && + action !== 'finish' + ) { + return null; + } + return { action }; +} + +export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: { + getSetupWindow: () => FocusableWindowLike | null; +}) { + return (): boolean => { + const window = deps.getSetupWindow(); + if (!window) return false; + window.focus(); + return true; + }; +} + +export function createHandleFirstRunSetupNavigationHandler(deps: { + parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null; + handleAction: (action: FirstRunSetupAction) => Promise; + logError: (message: string, error: unknown) => void; +}) { + return (params: { url: string; preventDefault: () => void }): boolean => { + const submission = deps.parseSubmissionUrl(params.url); + if (!submission) return false; + params.preventDefault(); + void deps.handleAction(submission.action).catch((error) => { + deps.logError('Failed handling first-run setup action', error); + }); + return true; + }; +} + +export function createOpenFirstRunSetupWindowHandler(deps: { + maybeFocusExistingSetupWindow: () => boolean; + createSetupWindow: () => TWindow; + getSetupSnapshot: () => Promise; + buildSetupHtml: (model: FirstRunSetupHtmlModel) => string; + parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null; + handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>; + markSetupInProgress: () => Promise; + markSetupCancelled: () => Promise; + isSetupCompleted: () => boolean; + clearSetupWindow: () => void; + setSetupWindow: (window: TWindow) => void; + encodeURIComponent: (value: string) => string; + logError: (message: string, error: unknown) => void; +}) { + return (): void => { + if (deps.maybeFocusExistingSetupWindow()) { + return; + } + + const setupWindow = deps.createSetupWindow(); + deps.setSetupWindow(setupWindow); + + const render = async (): Promise => { + const model = await deps.getSetupSnapshot(); + const html = deps.buildSetupHtml(model); + await setupWindow.loadURL( + `data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`, + ); + }; + + const handleNavigation = createHandleFirstRunSetupNavigationHandler({ + parseSubmissionUrl: deps.parseSubmissionUrl, + handleAction: async (action) => { + const result = await deps.handleAction(action); + if (result?.closeWindow) { + if (!setupWindow.isDestroyed()) { + setupWindow.close(); + } + return; + } + if (!setupWindow.isDestroyed()) { + await render(); + } + }, + logError: deps.logError, + }); + + setupWindow.webContents.on('will-navigate', (event, url) => { + handleNavigation({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + (event as { preventDefault?: () => void }).preventDefault?.(); + } + }, + }); + }); + + setupWindow.on('closed', () => { + if (!deps.isSetupCompleted()) { + void deps.markSetupCancelled().catch((error) => { + deps.logError('Failed marking first-run setup cancelled', error); + }); + } + deps.clearSetupWindow(); + }); + + void deps + .markSetupInProgress() + .then(() => render()) + .catch((error) => deps.logError('Failed opening first-run setup window', error)); + }; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 2140061..6293d69 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => { const buildTemplate = createBuildTrayMenuTemplateHandler({ buildTrayMenuTemplateRuntime: (handlers) => { handlers.openOverlay(); + handlers.openFirstRunSetup(); handlers.openYomitanSettings(); handlers.openRuntimeOptions(); handlers.openJellyfinSetup(); @@ -55,6 +56,8 @@ test('build tray template handler wires actions and init guards', () => { }, isOverlayRuntimeInitialized: () => initialized, setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + showFirstRunSetup: () => true, + openFirstRunSetupWindow: () => calls.push('setup'), openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), openJellyfinSetupWindow: () => calls.push('jellyfin'), @@ -67,6 +70,7 @@ test('build tray template handler wires actions and init guards', () => { assert.deepEqual(calls, [ 'init', 'visible:true', + 'setup', 'yomitan', 'runtime-options', 'jellyfin', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index e624f21..c3b6f0e 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -29,6 +29,8 @@ export function createResolveTrayIconPathHandler(deps: { export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { openOverlay: () => void; + openFirstRunSetup: () => void; + showFirstRunSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; openJellyfinSetup: () => void; @@ -38,6 +40,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; + showFirstRunSetup: () => boolean; + openFirstRunSetupWindow: () => void; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; openJellyfinSetupWindow: () => void; @@ -52,6 +56,10 @@ export function createBuildTrayMenuTemplateHandler(deps: { } deps.setVisibleOverlayVisible(true); }, + openFirstRunSetup: () => { + deps.openFirstRunSetupWindow(); + }, + showFirstRunSetup: deps.showFirstRunSetup(), openYomitanSettings: () => { deps.openYomitanSettings(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index ec697a5..644c358 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -25,6 +25,8 @@ test('tray main deps builders return mapped handlers', () => { initializeOverlayRuntime: () => calls.push('init'), isOverlayRuntimeInitialized: () => false, setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + showFirstRunSetup: () => true, + openFirstRunSetupWindow: () => calls.push('setup'), openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), openJellyfinSetupWindow: () => calls.push('jellyfin'), @@ -34,6 +36,8 @@ test('tray main deps builders return mapped handlers', () => { const template = menuDeps.buildTrayMenuTemplateRuntime({ openOverlay: () => calls.push('open-overlay'), + openFirstRunSetup: () => calls.push('open-setup'), + showFirstRunSetup: true, openYomitanSettings: () => calls.push('open-yomitan'), openRuntimeOptions: () => calls.push('open-runtime-options'), openJellyfinSetup: () => calls.push('open-jellyfin'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 4172768..3e37a6b 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -28,6 +28,8 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: { export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { openOverlay: () => void; + openFirstRunSetup: () => void; + showFirstRunSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; openJellyfinSetup: () => void; @@ -37,6 +39,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; + showFirstRunSetup: () => boolean; + openFirstRunSetupWindow: () => void; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; openJellyfinSetupWindow: () => void; @@ -48,6 +52,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: deps.initializeOverlayRuntime, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, setVisibleOverlayVisible: deps.setVisibleOverlayVisible, + showFirstRunSetup: deps.showFirstRunSetup, + openFirstRunSetupWindow: deps.openFirstRunSetupWindow, openYomitanSettings: deps.openYomitanSettings, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index fcfdb31..7d8df2b 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -27,6 +27,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => setVisibleOverlayVisible: (visible) => { visibleOverlay = visible; }, + showFirstRunSetup: () => true, + openFirstRunSetupWindow: () => {}, openYomitanSettings: () => {}, openRuntimeOptionsPalette: () => {}, openJellyfinSetupWindow: () => {}, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 6d9821c..4ff92e9 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -30,6 +30,8 @@ test('tray menu template contains expected entries and handlers', () => { const calls: string[] = []; const template = buildTrayMenuTemplateRuntime({ openOverlay: () => calls.push('overlay'), + openFirstRunSetup: () => calls.push('setup'), + showFirstRunSetup: true, openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptions: () => calls.push('runtime'), openJellyfinSetup: () => calls.push('jellyfin'), @@ -37,9 +39,26 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 7); + assert.equal(template.length, 8); template[0]!.click?.(); - template[5]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[6]!.click?.(); + template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[7]!.click?.(); assert.deepEqual(calls, ['overlay', 'separator', 'quit']); }); + +test('tray menu template omits first-run setup entry when setup is complete', () => { + const labels = buildTrayMenuTemplateRuntime({ + openOverlay: () => undefined, + openFirstRunSetup: () => undefined, + showFirstRunSetup: false, + openYomitanSettings: () => undefined, + openRuntimeOptions: () => undefined, + openJellyfinSetup: () => undefined, + openAnilistSetup: () => undefined, + quitApp: () => undefined, + }) + .map((entry) => entry.label) + .filter(Boolean); + + assert.equal(labels.includes('Complete Setup'), false); +}); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 50362f6..5d7ad41 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -31,6 +31,8 @@ export function resolveTrayIconPathRuntime(deps: { export type TrayMenuActionHandlers = { openOverlay: () => void; + openFirstRunSetup: () => void; + showFirstRunSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; openJellyfinSetup: () => void; @@ -48,6 +50,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open Overlay', click: handlers.openOverlay, }, + ...(handlers.showFirstRunSetup + ? [ + { + label: 'Complete Setup', + click: handlers.openFirstRunSetup, + }, + ] + : []), { label: 'Open Yomitan Settings', click: handlers.openYomitanSettings, diff --git a/src/main/state.ts b/src/main/state.ts index 0e89628..ca2679e 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -147,6 +147,7 @@ export interface AppState { yomitanParserWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null; jellyfinSetupWindow: BrowserWindow | null; + firstRunSetupWindow: BrowserWindow | null; yomitanParserReadyPromise: Promise | null; yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; @@ -193,6 +194,7 @@ export interface AppState { frequencyRankLookup: FrequencyDictionaryLookup; anilistSetupPageOpened: boolean; anilistRetryQueueState: AnilistRetryQueueState; + firstRunSetupCompleted: boolean; } export interface AppStateInitialValues { @@ -221,6 +223,7 @@ export function createAppState(values: AppStateInitialValues): AppState { yomitanParserWindow: null, anilistSetupWindow: null, jellyfinSetupWindow: null, + firstRunSetupWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, @@ -269,6 +272,7 @@ export function createAppState(values: AppStateInitialValues): AppState { frequencyRankLookup: () => null, anilistSetupPageOpened: false, anilistRetryQueueState: createInitialAnilistRetryQueueState(), + firstRunSetupCompleted: false, }; } diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts new file mode 100644 index 0000000..a7fddb4 --- /dev/null +++ b/src/shared/setup-state.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + createDefaultSetupState, + ensureDefaultConfigBootstrap, + getDefaultConfigDir, + getDefaultConfigFilePaths, + getSetupStatePath, + readSetupState, + resolveDefaultMpvInstallPaths, + writeSetupState, +} from './setup-state'; + +function withTempDir(fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-setup-state-test-')); + try { + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('getDefaultConfigDir prefers existing SubMiner config directory', () => { + const dir = getDefaultConfigDir({ + xdgConfigHome: '/tmp/xdg', + homeDir: '/tmp/home', + existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc', + }); + + assert.equal(dir, '/tmp/xdg/SubMiner'); +}); + +test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => { + withTempDir((root) => { + const configDir = path.join(root, 'SubMiner'); + ensureDefaultConfigBootstrap({ + configDir, + configFilePaths: getDefaultConfigFilePaths(configDir), + generateTemplate: () => '{\n "logging": {}\n}\n', + }); + + assert.equal(fs.existsSync(configDir), true); + assert.equal(fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'), '{\n "logging": {}\n}\n'); + + fs.writeFileSync(path.join(configDir, 'config.json'), '{"keep":true}\n'); + fs.rmSync(path.join(configDir, 'config.jsonc')); + ensureDefaultConfigBootstrap({ + configDir, + configFilePaths: getDefaultConfigFilePaths(configDir), + generateTemplate: () => 'should-not-write', + }); + + assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false); + assert.equal(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8'), '{"keep":true}\n'); + }); +}); + +test('readSetupState ignores invalid files and round-trips valid state', () => { + withTempDir((root) => { + const statePath = getSetupStatePath(root); + fs.writeFileSync(statePath, '{invalid'); + assert.equal(readSetupState(statePath), null); + + const state = createDefaultSetupState(); + state.status = 'completed'; + state.completionSource = 'user'; + state.lastSeenYomitanDictionaryCount = 2; + writeSetupState(statePath, state); + + assert.deepEqual(readSetupState(statePath), state); + }); +}); + +test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => { + assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), { + supported: true, + mpvConfigDir: '/tmp/xdg/mpv', + scriptsDir: '/tmp/xdg/mpv/scripts', + scriptOptsDir: '/tmp/xdg/mpv/script-opts', + pluginDir: '/tmp/xdg/mpv/scripts/subminer', + pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf', + }); + + assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), { + supported: true, + mpvConfigDir: '/Users/tester/Library/Application Support/mpv', + scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts', + scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts', + pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer', + pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf', + }); +}); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts new file mode 100644 index 0000000..08e00dd --- /dev/null +++ b/src/shared/setup-state.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveConfigDir } from '../config/path-resolution'; + +export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled'; +export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null; +export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; + +export interface SetupState { + version: 1; + status: SetupStateStatus; + completedAt: string | null; + completionSource: SetupCompletionSource; + lastSeenYomitanDictionaryCount: number; + pluginInstallStatus: SetupPluginInstallStatus; + pluginInstallPathSummary: string | null; +} + +export interface ConfigFilePaths { + jsoncPath: string; + jsonPath: string; +} + +export interface MpvInstallPaths { + supported: boolean; + mpvConfigDir: string; + scriptsDir: string; + scriptOptsDir: string; + pluginDir: string; + pluginConfigPath: string; +} + +function asObject(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function createDefaultSetupState(): SetupState { + return { + version: 1, + status: 'incomplete', + completedAt: null, + completionSource: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + }; +} + +export function normalizeSetupState(value: unknown): SetupState | null { + const record = asObject(value); + if (!record) return null; + const status = record.status; + const pluginInstallStatus = record.pluginInstallStatus; + const completionSource = record.completionSource; + + if ( + record.version !== 1 || + (status !== 'incomplete' && + status !== 'in_progress' && + status !== 'completed' && + status !== 'cancelled') || + (pluginInstallStatus !== 'unknown' && + pluginInstallStatus !== 'installed' && + pluginInstallStatus !== 'skipped' && + pluginInstallStatus !== 'failed') || + (completionSource !== null && + completionSource !== 'user' && + completionSource !== 'legacy_auto_detected') + ) { + return null; + } + + return { + version: 1, + status, + completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, + completionSource, + lastSeenYomitanDictionaryCount: + typeof record.lastSeenYomitanDictionaryCount === 'number' && + Number.isFinite(record.lastSeenYomitanDictionaryCount) && + record.lastSeenYomitanDictionaryCount >= 0 + ? Math.floor(record.lastSeenYomitanDictionaryCount) + : 0, + pluginInstallStatus, + pluginInstallPathSummary: + typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null, + }; +} + +export function isSetupCompleted(state: SetupState | null | undefined): boolean { + return state?.status === 'completed'; +} + +export function getDefaultConfigDir(options?: { + xdgConfigHome?: string; + homeDir?: string; + existsSync?: (candidate: string) => boolean; +}): string { + return resolveConfigDir({ + xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME, + homeDir: options?.homeDir ?? os.homedir(), + existsSync: options?.existsSync ?? fs.existsSync, + }); +} + +export function getDefaultConfigFilePaths(configDir: string): ConfigFilePaths { + return { + jsoncPath: path.join(configDir, 'config.jsonc'), + jsonPath: path.join(configDir, 'config.json'), + }; +} + +export function getSetupStatePath(configDir: string): string { + return path.join(configDir, 'setup-state.json'); +} + +export function readSetupState( + statePath: string, + deps?: { + existsSync?: (candidate: string) => boolean; + readFileSync?: (candidate: string, encoding: BufferEncoding) => string; + }, +): SetupState | null { + const existsSync = deps?.existsSync ?? fs.existsSync; + const readFileSync = deps?.readFileSync ?? fs.readFileSync; + if (!existsSync(statePath)) return null; + + try { + return normalizeSetupState(JSON.parse(readFileSync(statePath, 'utf8'))); + } catch { + return null; + } +} + +export function writeSetupState( + statePath: string, + state: SetupState, + deps?: { + mkdirSync?: (candidate: string, options: { recursive: true }) => void; + writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; + }, +): void { + const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; + const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; + mkdirSync(path.dirname(statePath), { recursive: true }); + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} + +export function ensureDefaultConfigBootstrap(options: { + configDir: string; + configFilePaths: ConfigFilePaths; + generateTemplate: () => string; + existsSync?: (candidate: string) => boolean; + mkdirSync?: (candidate: string, options: { recursive: true }) => void; + writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; +}): void { + const existsSync = options.existsSync ?? fs.existsSync; + const mkdirSync = options.mkdirSync ?? fs.mkdirSync; + const writeFileSync = options.writeFileSync ?? fs.writeFileSync; + + mkdirSync(options.configDir, { recursive: true }); + if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) { + return; + } + + writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8'); +} + +export function resolveDefaultMpvInstallPaths( + platform: NodeJS.Platform, + homeDir: string, + xdgConfigHome?: string, +): MpvInstallPaths { + const mpvConfigDir = + platform === 'darwin' + ? path.join(homeDir, 'Library', 'Application Support', 'mpv') + : platform === 'linux' + ? path.join((xdgConfigHome?.trim() || path.join(homeDir, '.config')), 'mpv') + : path.join(homeDir, 'AppData', 'Roaming', 'mpv'); + + return { + supported: platform === 'linux' || platform === 'darwin', + mpvConfigDir, + scriptsDir: path.join(mpvConfigDir, 'scripts'), + scriptOptsDir: path.join(mpvConfigDir, 'script-opts'), + pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'), + pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'), + }; +}