mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-07 03:22:17 -08:00
feat: add first-run setup flow
This commit is contained in:
33
README.md
33
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [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.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
107
launcher/setup-gate.test.ts
Normal file
107
launcher/setup-gate.test.ts
Normal file
@@ -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<SetupState | null> = [
|
||||
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);
|
||||
});
|
||||
41
launcher/setup-gate.ts
Normal file
41
launcher/setup-gate.ts
Normal file
@@ -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<void>;
|
||||
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<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (isSetupCompleted(deps.readSetupState())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.launchSetupApp();
|
||||
const result = await waitForSetupCompletion(deps);
|
||||
return result === 'completed';
|
||||
}
|
||||
14
package.json
14
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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
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<AppReadyRuntimeDeps> = {}) {
|
||||
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<AppReadyRuntimeDeps> = {}) {
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarmSubtitleDictionaries');
|
||||
},
|
||||
@@ -48,7 +57,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
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 () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
@@ -96,6 +97,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
|
||||
@@ -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<void>;
|
||||
createSubtitleTimingTracker: () => void;
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
texthookerOnlyMode: boolean;
|
||||
@@ -169,8 +182,10 @@ export function isAutoUpdateEnabledRuntime(
|
||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
||||
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<voi
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
@@ -210,6 +226,11 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const wsConfig = config.websocket || {};
|
||||
const wsEnabled = wsConfig.enabled ?? 'auto';
|
||||
const wsPort = wsConfig.port || deps.defaultWebsocketPort;
|
||||
const annotationWsConfig = config.annotationWebsocket || {};
|
||||
const annotationWsEnabled = annotationWsConfig.enabled !== false;
|
||||
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
|
||||
const texthookerPort = deps.defaultTexthookerPort;
|
||||
let texthookerWebsocketUrl: string | undefined;
|
||||
|
||||
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
|
||||
deps.startSubtitleWebsocket(wsPort);
|
||||
@@ -217,6 +238,17 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.log('mpv_websocket detected, skipping built-in WebSocket server');
|
||||
}
|
||||
|
||||
if (annotationWsEnabled) {
|
||||
deps.startAnnotationWebsocket(annotationWsPort);
|
||||
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
|
||||
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
|
||||
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
|
||||
}
|
||||
|
||||
if (config.texthooker?.launchAtStartup !== false) {
|
||||
deps.startTexthooker(texthookerPort, texthookerWebsocketUrl);
|
||||
}
|
||||
|
||||
deps.createSubtitleTimingTracker();
|
||||
if (deps.createImmersionTracker) {
|
||||
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
|
||||
@@ -233,6 +265,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
}
|
||||
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
223
src/main.ts
223
src/main.ts
@@ -239,6 +239,11 @@ import {
|
||||
resolveKeybindings,
|
||||
showDesktopNotification,
|
||||
} from './core/utils';
|
||||
import {
|
||||
ensureDefaultConfigBootstrap,
|
||||
getDefaultConfigFilePaths,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
} from './shared/setup-state';
|
||||
import {
|
||||
ImmersionTrackerService,
|
||||
JellyfinRemoteSessionService,
|
||||
@@ -296,6 +301,21 @@ import {
|
||||
upsertYomitanDictionarySettings,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
} from './core/services';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
type FirstRunSetupAction,
|
||||
} from './main/runtime/first-run-setup-window';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
@@ -495,6 +515,7 @@ const anilistUpdateQueue = createAnilistUpdateQueue(
|
||||
const isDev = process.argv.includes('--dev') || process.argv.includes('--debug');
|
||||
const texthookerService = new Texthooker();
|
||||
const subtitleWsService = new SubtitleWebSocket();
|
||||
const annotationSubtitleWsService = new SubtitleWebSocket();
|
||||
const logger = createLogger('main');
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -24,6 +24,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetup: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
103
src/main/runtime/first-run-setup-plugin.test.ts
Normal file
103
src/main/runtime/first-run-setup-plugin.test.ts
Normal file
@@ -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> | void): Promise<void> | 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);
|
||||
});
|
||||
100
src/main/runtime/first-run-setup-plugin.ts
Normal file
100
src/main/runtime/first-run-setup-plugin.ts
Normal file
@@ -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}.`,
|
||||
};
|
||||
}
|
||||
174
src/main/runtime/first-run-setup-service.test.ts
Normal file
174
src/main/runtime/first-run-setup-service.test.ts
Normal file
@@ -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> | void): Promise<void> | 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> = {}): 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');
|
||||
});
|
||||
});
|
||||
222
src/main/runtime/first-run-setup-service.ts
Normal file
222
src/main/runtime/first-run-setup-service.ts
Normal file
@@ -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<SetupStatusSnapshot>;
|
||||
getSetupStatus: () => Promise<SetupStatusSnapshot>;
|
||||
refreshStatus: (message?: string | null) => Promise<SetupStatusSnapshot>;
|
||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
|
||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
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<number>;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
installPlugin: () => Promise<PluginInstallResult>;
|
||||
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()),
|
||||
};
|
||||
}
|
||||
77
src/main/runtime/first-run-setup-window.test.ts
Normal file
77
src/main/runtime/first-run-setup-window.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
329
src/main/runtime/first-run-setup-window.ts
Normal file
329
src/main/runtime/first-run-setup-window.ts
Normal file
@@ -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 `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
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 `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>SubMiner First-Run Setup</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--base: #24273a;
|
||||
--mantle: #1e2030;
|
||||
--surface: #363a4f;
|
||||
--surface-strong: #494d64;
|
||||
--text: #cad3f5;
|
||||
--muted: #b8c0e0;
|
||||
--blue: #8aadf4;
|
||||
--green: #a6da95;
|
||||
--yellow: #eed49f;
|
||||
--red: #ed8796;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, var(--mantle), var(--base));
|
||||
color: var(--text);
|
||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
padding: 18px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.card {
|
||||
background: rgba(54, 58, 79, 0.92);
|
||||
border: 1px solid rgba(202, 211, 245, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.badge.ready { background: rgba(166, 218, 149, 0.16); color: var(--green); }
|
||||
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
|
||||
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
|
||||
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
button.primary {
|
||||
background: var(--blue);
|
||||
color: #1e2030;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(202, 211, 245, 0.12);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.message {
|
||||
min-height: 18px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>SubMiner setup</h1>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Config file</strong>
|
||||
<div class="meta">Default config directory seeded automatically.</div>
|
||||
</div>
|
||||
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>mpv plugin</strong>
|
||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||
</div>
|
||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
<div class="meta">${model.dictionaryCount} installed</div>
|
||||
</div>
|
||||
${renderStatusBadge(
|
||||
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
|
||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
||||
)}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||
</div>
|
||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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<unknown>;
|
||||
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<TWindow extends FirstRunSetupWindowLike>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
|
||||
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
|
||||
markSetupInProgress: () => Promise<unknown>;
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
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<void> => {
|
||||
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));
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -29,6 +29,8 @@ export function createResolveTrayIconPathHandler(deps: {
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -38,6 +40,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(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<TMenuItem>(deps: {
|
||||
}
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
showFirstRunSetup: deps.showFirstRunSetup(),
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -28,6 +28,8 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -37,6 +39,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(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<TMenuItem>(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,
|
||||
|
||||
@@ -27,6 +27,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlay = visible;
|
||||
},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface AppState {
|
||||
yomitanParserWindow: BrowserWindow | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
jellyfinSetupWindow: BrowserWindow | null;
|
||||
firstRunSetupWindow: BrowserWindow | null;
|
||||
yomitanParserReadyPromise: Promise<void> | null;
|
||||
yomitanParserInitPromise: Promise<boolean> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
95
src/shared/setup-state.test.ts
Normal file
95
src/shared/setup-state.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
192
src/shared/setup-state.ts
Normal file
192
src/shared/setup-state.ts
Normal file
@@ -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<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user