From 1b56360a24c23f39480bd9bc1dea424811b65c14 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 12 Mar 2026 01:17:34 -0700 Subject: [PATCH] feat(yomitan): add read-only external profile support for shared dictionaries (#18) --- CHANGELOG.md | 9 ++ changes/yomitan-external-profile-read-only.md | 4 + config.example.jsonc | 11 ++ docs-site/changelog.md | 7 + docs-site/character-dictionary.md | 4 + docs-site/configuration.md | 28 ++++ docs-site/public/config.example.jsonc | 11 ++ docs-site/troubleshooting.md | 1 + launcher/commands/playback-command.ts | 2 + launcher/config-domain-parsers.test.ts | 34 ++++ launcher/config.ts | 17 ++ launcher/setup-gate.test.ts | 34 +++- launcher/setup-gate.ts | 4 + package.json | 2 +- src/config/config.test.ts | 1 + src/config/definitions.ts | 3 +- .../definitions/defaults-integrations.ts | 12 +- .../definitions/domain-registry.test.ts | 2 + .../definitions/options-integrations.ts | 7 + src/config/definitions/template-sections.ts | 10 ++ src/config/resolve/integrations.ts | 29 ++++ src/config/resolve/jellyfin.test.ts | 41 +++++ src/core/services/app-ready.test.ts | 4 +- .../services/overlay-window-config.test.ts | 26 ++- src/core/services/overlay-window-options.ts | 39 +++++ src/core/services/overlay-window.ts | 31 +--- src/core/services/startup.ts | 2 + src/core/services/tokenizer.ts | 5 +- .../tokenizer/yomitan-parser-runtime.ts | 9 +- src/core/services/yomitan-extension-loader.ts | 93 +++++++---- .../services/yomitan-extension-paths.test.ts | 17 ++ src/core/services/yomitan-extension-paths.ts | 13 ++ .../yomitan-extension-runtime-state.test.ts | 45 ++++++ .../yomitan-extension-runtime-state.ts | 34 ++++ src/core/services/yomitan-settings.ts | 5 +- src/main.ts | 97 ++++++++++-- .../runtime/app-runtime-main-deps.test.ts | 11 +- src/main/runtime/app-runtime-main-deps.ts | 4 + .../character-dictionary-availability.test.ts | 20 +++ .../character-dictionary-availability.ts | 10 ++ .../runtime/first-run-setup-service.test.ts | 148 ++++++++++++++++++ src/main/runtime/first-run-setup-service.ts | 81 ++++++++-- .../runtime/first-run-setup-window.test.ts | 29 ++++ src/main/runtime/first-run-setup-window.ts | 27 +++- .../overlay-window-factory-main-deps.test.ts | 3 + .../overlay-window-factory-main-deps.ts | 5 + .../runtime/overlay-window-factory.test.ts | 3 + src/main/runtime/overlay-window-factory.ts | 5 + .../overlay-window-runtime-handlers.test.ts | 9 +- .../subtitle-tokenization-main-deps.ts | 1 + ...yomitan-extension-loader-main-deps.test.ts | 13 +- .../yomitan-extension-loader-main-deps.ts | 2 + .../runtime/yomitan-extension-loader.test.ts | 14 +- src/main/runtime/yomitan-extension-loader.ts | 4 + .../runtime/yomitan-extension-runtime.test.ts | 22 ++- src/main/runtime/yomitan-extension-runtime.ts | 2 + .../runtime/yomitan-profile-policy.test.ts | 36 +++++ src/main/runtime/yomitan-profile-policy.ts | 25 +++ .../runtime/yomitan-read-only-log.test.ts | 24 +++ src/main/runtime/yomitan-read-only-log.ts | 25 +++ .../runtime/yomitan-settings-opener.test.ts | 10 +- src/main/runtime/yomitan-settings-opener.ts | 9 ++ .../runtime/yomitan-settings-runtime.test.ts | 33 +++- src/main/state.ts | 4 +- src/shared/setup-state.test.ts | 54 ++++++- src/shared/setup-state.ts | 31 ++-- src/types.ts | 8 + 67 files changed, 1230 insertions(+), 135 deletions(-) create mode 100644 changes/yomitan-external-profile-read-only.md create mode 100644 src/core/services/overlay-window-options.ts create mode 100644 src/core/services/yomitan-extension-runtime-state.test.ts create mode 100644 src/core/services/yomitan-extension-runtime-state.ts create mode 100644 src/main/runtime/character-dictionary-availability.test.ts create mode 100644 src/main/runtime/character-dictionary-availability.ts create mode 100644 src/main/runtime/yomitan-profile-policy.test.ts create mode 100644 src/main/runtime/yomitan-profile-policy.ts create mode 100644 src/main/runtime/yomitan-read-only-log.test.ts create mode 100644 src/main/runtime/yomitan-read-only-log.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f59856..eb775c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.6.2 (2026-03-12) + +### Changed +- Config: Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode. +- Config: SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile. +- Config: Launcher-managed playback now respects `yomitan.externalProfilePath` and no longer forces first-run setup when external Yomitan is configured. +- Config: SubMiner now seeds `config.jsonc` even when the default config directory already exists. +- Config: First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed. + ## v0.6.1 (2026-03-12) ### Added diff --git a/changes/yomitan-external-profile-read-only.md b/changes/yomitan-external-profile-read-only.md new file mode 100644 index 0000000..90743bf --- /dev/null +++ b/changes/yomitan-external-profile-read-only.md @@ -0,0 +1,4 @@ +type: changed +area: yomitan + +- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling. diff --git a/config.example.jsonc b/config.example.jsonc index a946f28..c1ce0ee 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -385,6 +385,17 @@ } // Character dictionary setting. }, // Anilist API credentials and update behavior. + // ========================================== + // Yomitan + // Optional external Yomitan profile integration. + // Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode. + // For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay. + // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. + // ========================================== + "yomitan": { + "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + }, // Optional external Yomitan profile integration. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 842286a..6f0ffad 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,12 @@ # Changelog +## v0.6.2 (2026-03-12) +- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode. +- Reused external Yomitan dictionaries/settings without writing back to that profile. +- Let launcher-managed playback honor external Yomitan config instead of forcing first-run setup. +- Seeded `config.jsonc` even when the default config directory already exists. +- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed. + ## v0.6.0 (2026-03-12) - Added Chrome Gamepad API controller support for keyboard-only overlay mode. - Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index e46f88b..789913d 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -62,6 +62,10 @@ Character dictionary sync is disabled by default. To turn it on: The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot. ::: +::: warning +If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled. +::: + ## Name Generation A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for: diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2f6550c..4a13115 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -113,6 +113,7 @@ The configuration file includes several main sections: - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**AniList**](#anilist) - Optional post-watch progress updates +- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath` - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite @@ -1017,6 +1018,33 @@ AniList CLI commands: - `--anilist-setup`: open AniList setup/auth flow helper window. - `--anilist-retry-queue`: process one ready retry queue item immediately. +### Yomitan + +SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode. + +For GameSentenceMiner on Linux, the default overlay profile path is typically `~/.config/gsm_overlay`. + +```json +{ + "yomitan": { + "externalProfilePath": "/home/you/.config/gsm_overlay" + } +} +``` + +| Option | Values | Description | +| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. | + +External-profile mode behavior: + +- SubMiner uses the external profile's Yomitan extension/session instead of its local copy. +- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries. +- SubMiner does not open its own Yomitan settings window in this mode. +- SubMiner does not import, delete, or update dictionaries/settings in the external profile. +- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations. +- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one. + ### Jellyfin Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index a946f28..c1ce0ee 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -385,6 +385,17 @@ } // Character dictionary setting. }, // Anilist API credentials and update behavior. + // ========================================== + // Yomitan + // Optional external Yomitan profile integration. + // Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode. + // For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay. + // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. + // ========================================== + "yomitan": { + "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + }, // Optional external Yomitan profile integration. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 4705de8..1b49b8a 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -182,6 +182,7 @@ If you installed from the AppImage and see this error, the package may be incomp - Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension". - Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported. +- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window. - If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below. ## MeCab / Tokenization diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 7aa1a96..e82af32 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -21,6 +21,7 @@ import { getSetupStatePath, readSetupState, } from '../../src/shared/setup-state.js'; +import { hasLauncherExternalYomitanProfileConfig } from '../config.js'; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_POLL_INTERVAL_MS = 500; @@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis const statePath = getSetupStatePath(configDir); const ready = await ensureLauncherSetupReady({ readSetupState: () => readSetupState(statePath), + isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(), launchSetupApp: () => { const setupArgs = ['--background', '--setup']; if (args.logLevel) { diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 8f28b62..4f3f090 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; +import { readExternalYomitanProfilePath } from './config.js'; import { getPluginConfigCandidates, parsePluginRuntimeConfigContent, @@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => { test('getDefaultSocketPath returns Windows named pipe default', () => { assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket'); }); + +test('readExternalYomitanProfilePath detects configured external profile paths', () => { + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: ' ~/.config/gsm_overlay ', + }, + }), + '~/.config/gsm_overlay', + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: ' ', + }, + }), + null, + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: null, + }), + null, + ); + assert.equal( + readExternalYomitanProfilePath({ + yomitan: { + externalProfilePath: 123, + }, + } as never), + null, + ); +}); diff --git a/launcher/config.ts b/launcher/config.ts index 5cbc196..cd3bc16 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -17,6 +17,19 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; +export function readExternalYomitanProfilePath(root: Record | null): string | null { + const yomitan = + root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan) + ? (root.yomitan as Record) + : null; + const externalProfilePath = yomitan?.externalProfilePath; + if (typeof externalProfilePath !== 'string') { + return null; + } + const trimmed = externalProfilePath.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { const root = readLauncherMainConfigObject(); if (!root) return {}; @@ -29,6 +42,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { return parseLauncherJellyfinConfig(root); } +export function hasLauncherExternalYomitanProfileConfig(): boolean { + return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; +} + export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { return readPluginRuntimeConfigValue(logLevel); } diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index db3c8fb..2e6c1b8 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async () const sequence: Array = [ null, { - version: 2, + version: 3, status: 'in_progress', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async () windowsMpvShortcutLastStatus: 'unknown', }, { - version: 2, + version: 3, status: 'completed', completedAt: '2026-03-07T00:00:00.000Z', completionSource: 'user', + yomitanSetupMode: 'internal', lastSeenYomitanDictionaryCount: 1, pluginInstallStatus: 'skipped', pluginInstallPathSummary: null, @@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet if (reads === 1) return null; if (reads === 2) { return { - version: 2, + version: 3, status: 'in_progress', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet }; } return { - version: 2, + version: 3, status: 'completed', completedAt: '2026-03-07T00:00:00.000Z', completionSource: 'user', + yomitanSetupMode: 'internal', lastSeenYomitanDictionaryCount: 1, pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', @@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet assert.deepEqual(calls, ['launch']); }); +test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => { + const calls: string[] = []; + + const ready = await ensureLauncherSetupReady({ + readSetupState: () => null, + isExternalYomitanConfigured: () => true, + launchSetupApp: () => { + calls.push('launch'); + }, + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(ready, true); + assert.deepEqual(calls, []); +}); + test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { const result = await ensureLauncherSetupReady({ readSetupState: () => ({ - version: 2, + version: 3, status: 'cancelled', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts index 3d37fb7..ebd9b36 100644 --- a/launcher/setup-gate.ts +++ b/launcher/setup-gate.ts @@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: { export async function ensureLauncherSetupReady(deps: { readSetupState: () => SetupState | null; + isExternalYomitanConfigured?: () => boolean; launchSetupApp: () => void; sleep: (ms: number) => Promise; now: () => number; timeoutMs: number; pollIntervalMs: number; }): Promise { + if (deps.isExternalYomitanConfigured?.()) { + return true; + } if (isSetupCompleted(deps.readSetupState())) { return true; } diff --git a/package.json b/package.json index 468366c..60428fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.6.1", + "version": "0.6.2", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 1e609d3..c8e62f8 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false); + assert.equal(config.yomitan.externalProfilePath, ''); assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, false); diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 12e1b46..d8a8e55 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -32,7 +32,7 @@ const { startupWarmups, auto_start_overlay, } = CORE_DEFAULT_CONFIG; -const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } = +const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; @@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { auto_start_overlay, jimaku, anilist, + yomitan, jellyfin, discordPresence, ai, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 0df432b..e1c9f73 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, - 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen' + | 'ankiConnect' + | 'jimaku' + | 'anilist' + | 'yomitan' + | 'jellyfin' + | 'discordPresence' + | 'ai' + | 'youtubeSubgen' > = { ankiConnect: { enabled: false, @@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, }, }, + yomitan: { + externalProfilePath: '', + }, jellyfin: { enabled: false, serverUrl: '', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index a7859e3..fdce816 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', () 'ankiConnect.enabled', 'anilist.characterDictionary.enabled', 'anilist.characterDictionary.collapsibleSections.description', + 'yomitan.externalProfilePath', 'immersionTracking.enabled', ]) { assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`); @@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () => 'startupWarmups', 'subtitleStyle', 'ankiConnect', + 'yomitan', 'immersionTracking', ]; diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 7e786ee..91947c6 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -211,6 +211,13 @@ export function buildIntegrationConfigOptionRegistry( description: 'Open the Voiced by section by default in character dictionary glossary entries.', }, + { + path: 'yomitan.externalProfilePath', + kind: 'string', + defaultValue: defaultConfig.yomitan.externalProfilePath, + description: + 'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay', + }, { path: 'jellyfin.enabled', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 93554c5..d25e8d1 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -137,6 +137,16 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: 'anilist', }, + { + title: 'Yomitan', + description: [ + 'Optional external Yomitan profile integration.', + 'Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.', + 'For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.', + 'In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.', + ], + key: 'yomitan', + }, { title: 'Jellyfin', description: [ diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 634869e..5b4d6d9 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -1,6 +1,19 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; +function normalizeExternalProfilePath(value: string): string { + const trimmed = value.trim(); + if (trimmed === '~') { + return os.homedir(); + } + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return trimmed; +} + export function applyIntegrationConfig(context: ResolveContext): void { const { src, resolved, warn } = context; @@ -199,6 +212,22 @@ export function applyIntegrationConfig(context: ResolveContext): void { } } + if (isObject(src.yomitan)) { + const externalProfilePath = asString(src.yomitan.externalProfilePath); + if (externalProfilePath !== undefined) { + resolved.yomitan.externalProfilePath = normalizeExternalProfilePath(externalProfilePath); + } else if (src.yomitan.externalProfilePath !== undefined) { + warn( + 'yomitan.externalProfilePath', + src.yomitan.externalProfilePath, + resolved.yomitan.externalProfilePath, + 'Expected string.', + ); + } + } else if (src.yomitan !== undefined) { + warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.'); + } + if (isObject(src.jellyfin)) { const enabled = asBoolean(src.jellyfin.enabled); if (enabled !== undefined) { diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index 5f43aed..5cc211d 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -1,5 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { createResolveContext } from './context'; import { applyIntegrationConfig } from './integrations'; @@ -104,3 +106,42 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'), ); }); + +test('yomitan externalProfilePath is trimmed and invalid values warn', () => { + const { context, warnings } = createResolveContext({ + yomitan: { + externalProfilePath: ' /tmp/gsm-profile ', + }, + }); + + applyIntegrationConfig(context); + + assert.equal(context.resolved.yomitan.externalProfilePath, '/tmp/gsm-profile'); + + const invalid = createResolveContext({ + yomitan: { + externalProfilePath: 42 as never, + }, + }); + + applyIntegrationConfig(invalid.context); + + assert.equal(invalid.context.resolved.yomitan.externalProfilePath, ''); + assert.ok(invalid.warnings.some((warning) => warning.path === 'yomitan.externalProfilePath')); +}); + +test('yomitan externalProfilePath expands leading tilde to the current home directory', () => { + const homeDir = os.homedir(); + const { context } = createResolveContext({ + yomitan: { + externalProfilePath: '~/.config/gsm_overlay', + }, + }); + + applyIntegrationConfig(context); + + assert.equal( + context.resolved.yomitan.externalProfilePath, + path.join(homeDir, '.config', 'gsm_overlay'), + ); +}); diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 5b56d7a..c357f36 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns await runAppReadyRuntime(deps); assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true); - assert.equal(calls.includes('reloadConfig'), false); + assert.equal(calls.includes('reloadConfig'), true); assert.equal(calls.includes('getResolvedConfig'), false); assert.equal(calls.includes('getConfigWarnings'), false); assert.equal(calls.includes('setLogLevel:warn:config'), false); @@ -170,6 +170,8 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns assert.equal(calls.includes('loadYomitanExtension'), true); assert.equal(calls.includes('handleFirstRunSetup'), true); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs')); + assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('reloadConfig')); + assert.ok(calls.indexOf('reloadConfig') < calls.indexOf('handleFirstRunSetup')); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); }); diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index c6964a2..5b2a467 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -1,11 +1,27 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; +import { buildOverlayWindowOptions } from './overlay-window-options'; test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => { - const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts'); - const source = fs.readFileSync(sourcePath, 'utf8'); + const options = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession: null, + }); - assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m); + assert.equal(options.webPreferences?.sandbox, false); +}); + +test('overlay window config uses the provided Yomitan session when available', () => { + const yomitanSession = { id: 'session' } as never; + const withSession = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession, + }); + const withoutSession = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession: null, + }); + + assert.equal(withSession.webPreferences?.session, yomitanSession); + assert.equal(withoutSession.webPreferences?.session, undefined); }); diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts new file mode 100644 index 0000000..80619b2 --- /dev/null +++ b/src/core/services/overlay-window-options.ts @@ -0,0 +1,39 @@ +import type { BrowserWindowConstructorOptions, Session } from 'electron'; +import * as path from 'path'; +import type { OverlayWindowKind } from './overlay-window-input'; + +export function buildOverlayWindowOptions( + kind: OverlayWindowKind, + options: { + isDev: boolean; + yomitanSession?: Session | null; + }, +): BrowserWindowConstructorOptions { + const showNativeDebugFrame = process.platform === 'win32' && options.isDev; + + return { + show: false, + width: 800, + height: 600, + x: 0, + y: 0, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + hasShadow: false, + focusable: true, + acceptFirstMouse: true, + ...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}), + webPreferences: { + preload: path.join(__dirname, '..', '..', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + webSecurity: true, + session: options.yomitanSession ?? undefined, + additionalArguments: [`--overlay-layer=${kind}`], + }, + }; +} diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index b1dd61c..773b0f5 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, type Session } from 'electron'; import * as path from 'path'; import { WindowGeometry } from '../../types'; import { createLogger } from '../../logger'; @@ -7,6 +7,7 @@ import { handleOverlayWindowBeforeInputEvent, type OverlayWindowKind, } from './overlay-window-input'; +import { buildOverlayWindowOptions } from './overlay-window-options'; const logger = createLogger('main:overlay-window'); const overlayWindowLayerByInstance = new WeakMap(); @@ -78,33 +79,10 @@ export function createOverlayWindow( tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; onWindowClosed: (kind: OverlayWindowKind) => void; + yomitanSession?: Session | null; }, ): BrowserWindow { - const showNativeDebugFrame = process.platform === 'win32' && options.isDev; - const window = new BrowserWindow({ - show: false, - width: 800, - height: 600, - x: 0, - y: 0, - transparent: true, - frame: false, - alwaysOnTop: true, - skipTaskbar: true, - resizable: false, - hasShadow: false, - focusable: true, - acceptFirstMouse: true, - ...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}), - webPreferences: { - preload: path.join(__dirname, '..', '..', 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - webSecurity: true, - additionalArguments: [`--overlay-layer=${kind}`], - }, - }); + const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); options.ensureOverlayWindowLevel(window); loadOverlayWindowLayer(window, kind); @@ -170,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): loadOverlayWindowLayer(window, layer); } +export { buildOverlayWindowOptions } from './overlay-window-options'; export type { OverlayWindowKind } from './overlay-window-input'; diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index e3a1771..67d78bf 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -63,6 +64,7 @@ interface MecabTokenizerLike { export interface TokenizerDepsRuntimeOptions { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -182,6 +184,7 @@ export function createTokenizerDepsRuntime( return { getYomitanExt: options.getYomitanExt, + getYomitanSession: options.getYomitanSession, getYomitanParserWindow: options.getYomitanParserWindow, setYomitanParserWindow: options.setYomitanParserWindow, getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index d9bd9bb..fddda4e 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { selectYomitanParseTokens } from './parser-selection-stage'; @@ -10,6 +10,7 @@ interface LoggerLike { interface YomitanParserRuntimeDeps { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -465,6 +466,7 @@ async function ensureYomitanParserWindow( const initPromise = (async () => { const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const parserWindow = new BrowserWindow({ show: false, width: 800, @@ -472,7 +474,7 @@ async function ensureYomitanParserWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); deps.setYomitanParserWindow(parserWindow); @@ -539,6 +541,7 @@ async function createYomitanExtensionWindow( } const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const window = new BrowserWindow({ show: false, width: 1200, @@ -546,7 +549,7 @@ async function createYomitanExtensionWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 407c14e..0161580 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -1,12 +1,18 @@ import electron from 'electron'; -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import * as fs from 'fs'; +import * as path from 'path'; import { createLogger } from '../../logger'; import { ensureExtensionCopy } from './yomitan-extension-copy'; import { getYomitanExtensionSearchPaths, + resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; +import { + clearYomitanExtensionRuntimeState, + clearYomitanParserRuntimeState, +} from './yomitan-extension-runtime-state'; const { session } = electron; const logger = createLogger('main:yomitan-extension-loader'); @@ -14,51 +20,82 @@ const logger = createLogger('main:yomitan-extension-loader'); export interface YomitanExtensionLoaderDeps { userDataPath: string; extensionPath?: string; + externalProfilePath?: string; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserReadyPromise: (promise: Promise | null) => void; setYomitanParserInitPromise: (promise: Promise | null) => void; setYomitanExtension: (extension: Extension | null) => void; + setYomitanSession: (session: Session | null) => void; } export async function loadYomitanExtension( deps: YomitanExtensionLoaderDeps, ): Promise { - const searchPaths = getYomitanExtensionSearchPaths({ - explicitPath: deps.extensionPath, - moduleDir: __dirname, - resourcesPath: process.resourcesPath, - userDataPath: deps.userDataPath, - }); - let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync); + const clearRuntimeState = () => + clearYomitanExtensionRuntimeState({ + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + setYomitanExtension: () => deps.setYomitanExtension(null), + setYomitanSession: () => deps.setYomitanSession(null), + }); + const clearParserState = () => + clearYomitanParserRuntimeState({ + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + }); + const externalProfilePath = deps.externalProfilePath?.trim() ?? ''; + let extPath: string | null = null; + let targetSession: Session = session.defaultSession; - if (!extPath) { - logger.error('Yomitan extension not found in any search path'); - logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths); - return null; + if (externalProfilePath) { + const resolvedProfilePath = path.resolve(externalProfilePath); + extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync); + if (!extPath) { + logger.error('External Yomitan extension not found in configured profile path'); + logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions')); + clearRuntimeState(); + return null; + } + + targetSession = session.fromPath(resolvedProfilePath); + } else { + const searchPaths = getYomitanExtensionSearchPaths({ + explicitPath: deps.extensionPath, + moduleDir: __dirname, + resourcesPath: process.resourcesPath, + userDataPath: deps.userDataPath, + }); + extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync); + + if (!extPath) { + logger.error('Yomitan extension not found in any search path'); + logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths); + clearRuntimeState(); + return null; + } + + const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath); + if (extensionCopy.copied) { + logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`); + } + extPath = extensionCopy.targetDir; } - const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath); - if (extensionCopy.copied) { - logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`); - } - extPath = extensionCopy.targetDir; - - const parserWindow = deps.getYomitanParserWindow(); - if (parserWindow && !parserWindow.isDestroyed()) { - parserWindow.destroy(); - } - deps.setYomitanParserWindow(null); - deps.setYomitanParserReadyPromise(null); - deps.setYomitanParserInitPromise(null); + clearParserState(); + deps.setYomitanSession(targetSession); try { - const extensions = session.defaultSession.extensions; + const extensions = targetSession.extensions; const extension = extensions ? await extensions.loadExtension(extPath, { allowFileAccess: true, }) - : await session.defaultSession.loadExtension(extPath, { + : await targetSession.loadExtension(extPath, { allowFileAccess: true, }); deps.setYomitanExtension(extension); @@ -66,7 +103,7 @@ export async function loadYomitanExtension( } catch (err) { logger.error('Failed to load Yomitan extension:', (err as Error).message); logger.error('Full error:', err); - deps.setYomitanExtension(null); + clearRuntimeState(); return null; } } diff --git a/src/core/services/yomitan-extension-paths.test.ts b/src/core/services/yomitan-extension-paths.test.ts index a105861..95d6019 100644 --- a/src/core/services/yomitan-extension-paths.test.ts +++ b/src/core/services/yomitan-extension-paths.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { getYomitanExtensionSearchPaths, + resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; @@ -51,3 +52,19 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani assert.equal(resolved, null); }); + +test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) => + candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'), + ); + + assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan')); +}); + +test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false); + + assert.equal(resolved, null); +}); diff --git a/src/core/services/yomitan-extension-paths.ts b/src/core/services/yomitan-extension-paths.ts index fd65379..5256b45 100644 --- a/src/core/services/yomitan-extension-paths.ts +++ b/src/core/services/yomitan-extension-paths.ts @@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath( ): string | null { return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync); } + +export function resolveExternalYomitanExtensionPath( + externalProfilePath: string, + existsSync: (path: string) => boolean = fs.existsSync, +): string | null { + const normalizedProfilePath = externalProfilePath.trim(); + if (!normalizedProfilePath) { + return null; + } + + const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan'); + return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null; +} diff --git a/src/core/services/yomitan-extension-runtime-state.test.ts b/src/core/services/yomitan-extension-runtime-state.test.ts new file mode 100644 index 0000000..886c49e --- /dev/null +++ b/src/core/services/yomitan-extension-runtime-state.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state'; + +test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => { + const calls: string[] = []; + const parserWindow = { + isDestroyed: () => false, + destroy: () => { + calls.push('destroy'); + }, + }; + + clearYomitanParserRuntimeState({ + getYomitanParserWindow: () => parserWindow as never, + setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`), + setYomitanParserReadyPromise: (promise) => + calls.push(`ready:${promise === null ? 'null' : 'set'}`), + setYomitanParserInitPromise: (promise) => + calls.push(`init:${promise === null ? 'null' : 'set'}`), + }); + + assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']); +}); + +test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => { + const calls: string[] = []; + const parserWindow = { + isDestroyed: () => true, + destroy: () => { + calls.push('destroy'); + }, + }; + + clearYomitanParserRuntimeState({ + getYomitanParserWindow: () => parserWindow as never, + setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`), + setYomitanParserReadyPromise: (promise) => + calls.push(`ready:${promise === null ? 'null' : 'set'}`), + setYomitanParserInitPromise: (promise) => + calls.push(`init:${promise === null ? 'null' : 'set'}`), + }); + + assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']); +}); diff --git a/src/core/services/yomitan-extension-runtime-state.ts b/src/core/services/yomitan-extension-runtime-state.ts new file mode 100644 index 0000000..3106593 --- /dev/null +++ b/src/core/services/yomitan-extension-runtime-state.ts @@ -0,0 +1,34 @@ +type ParserWindowLike = { + isDestroyed?: () => boolean; + destroy?: () => void; +} | null; + +export interface YomitanParserRuntimeStateDeps { + getYomitanParserWindow: () => ParserWindowLike; + setYomitanParserWindow: (window: null) => void; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + setYomitanParserInitPromise: (promise: Promise | null) => void; +} + +export interface YomitanExtensionRuntimeStateDeps extends YomitanParserRuntimeStateDeps { + setYomitanExtension: (extension: null) => void; + setYomitanSession: (session: null) => void; +} + +export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDeps): void { + const parserWindow = deps.getYomitanParserWindow(); + if (parserWindow && !parserWindow.isDestroyed?.()) { + parserWindow.destroy?.(); + } + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); +} + +export function clearYomitanExtensionRuntimeState( + deps: YomitanExtensionRuntimeStateDeps, +): void { + clearYomitanParserRuntimeState(deps); + deps.setYomitanExtension(null); + deps.setYomitanSession(null); +} diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index d444b1b..b03edcc 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -1,5 +1,5 @@ import electron from 'electron'; -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import { createLogger } from '../../logger'; const { BrowserWindow: ElectronBrowserWindow, session } = electron; @@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions { yomitanExt: Extension | null; getExistingWindow: () => BrowserWindow | null; setWindow: (window: BrowserWindow | null) => void; + yomitanSession?: Session | null; onWindowClosed?: () => void; } @@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: options.yomitanSession ?? session.defaultSession, }, }); options.setWindow(settingsWindow); diff --git a/src/main.ts b/src/main.ts index 56157c9..98a4e84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { shell, protocol, Extension, + Session, Menu, nativeImage, Tray, @@ -376,6 +377,8 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/ import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; +import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; +import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, shouldForceOverrideYomitanAnkiServer, @@ -691,6 +694,7 @@ const firstRunSetupService = createFirstRunSetupService({ }); return dictionaries.length; }, + isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, detectPluginInstalled: () => { const installPaths = resolveDefaultMpvInstallPaths( process.platform, @@ -1327,7 +1331,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt getConfig: () => { const config = getResolvedConfig().anilist.characterDictionary; return { - enabled: config.enabled, + enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(), maxLoaded: config.maxLoaded, profileScope: config.profileScope, }; @@ -1347,6 +1351,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, importYomitanDictionary: async (zipPath) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( + formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath), + ); + return false; + } await ensureYomitanExtensionLoaded(); return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), @@ -1354,6 +1364,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, deleteYomitanDictionary: async (dictionaryTitle) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( + formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), + ); + return false; + } await ensureYomitanExtensionLoaded(); return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), @@ -1361,6 +1377,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( + formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), + ); + return false; + } await ensureYomitanExtensionLoaded(); return await upsertYomitanDictionarySettings( dictionaryTitle, @@ -1814,6 +1836,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ configReady: snapshot.configReady, dictionaryCount: snapshot.dictionaryCount, canFinish: snapshot.canFinish, + externalYomitanConfigured: snapshot.externalYomitanConfigured, pluginStatus: snapshot.pluginStatus, pluginInstallPathSummary: snapshot.pluginInstallPathSummary, windowsMpvShortcuts: snapshot.windowsMpvShortcuts, @@ -1837,8 +1860,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return; } if (submission.action === 'open-yomitan-settings') { - openYomitanSettings(); - firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.'; + firstRunSetupMessage = openYomitanSettings() + ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' + : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; return; } if (submission.action === 'refresh') { @@ -2320,6 +2344,7 @@ const { appState.yomitanParserWindow = null; appState.yomitanParserReadyPromise = null; appState.yomitanParserInitPromise = null; + appState.yomitanSession = null; }, getWindowTracker: () => appState.windowTracker, flushMpvLog: () => flushPendingMpvLogWrites(), @@ -2737,6 +2762,9 @@ const { ); }, scheduleCharacterDictionarySync: () => { + if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) { + return; + } characterDictionaryAutoSyncRuntime.scheduleSync(); }, updateCurrentMediaTitle: (title) => { @@ -2780,6 +2808,7 @@ const { tokenizer: { buildTokenizerDepsMainDeps: { getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; @@ -2813,7 +2842,9 @@ const { 'subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt, ), - getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled, + getCharacterDictionaryEnabled: () => + getResolvedConfig().anilist.characterDictionary.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled(), getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( @@ -2987,7 +3018,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); - if (extension) { + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -2995,7 +3026,7 @@ async function loadYomitanExtension(): Promise { async function ensureYomitanExtensionLoaded(): Promise { const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - if (extension) { + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -3010,6 +3041,7 @@ function getPreferredYomitanAnkiServerUrl(): string { function getYomitanParserRuntimeDeps() { return { getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window: BrowserWindow | null) => { appState.yomitanParserWindow = window; @@ -3026,6 +3058,10 @@ function getYomitanParserRuntimeDeps() { } async function syncYomitanDefaultProfileAnkiServer(): Promise { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + return; + } + const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { return; @@ -3079,8 +3115,19 @@ function initializeOverlayRuntime(): void { syncOverlayMpvSubtitleSuppression(); } -function openYomitanSettings(): void { +function openYomitanSettings(): boolean { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + const message = + 'Yomitan settings unavailable while using read-only external-profile mode.'; + logger.warn( + 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', + ); + showDesktopNotification('SubMiner', { body: message }); + showMpvOsd(message); + return false; + } openYomitanSettingsHandler(); + return true; } const { @@ -3496,8 +3543,13 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ openJellyfinSetupWindow: () => openJellyfinSetupWindow(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), - generateCharacterDictionary: (targetPath?: string) => - characterDictionaryRuntime.generateForCurrentMedia(targetPath), + generateCharacterDictionary: async (targetPath?: string) => { + const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); + if (disabledReason) { + throw new Error(disabledReason); + } + return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); + }, runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), @@ -3520,10 +3572,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - isOverlayVisible: (windowKind) => - windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, - tryHandleOverlayShortcutLocalFallback: (input) => - overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + isOverlayVisible: (windowKind) => + windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, + getYomitanSession: () => appState.yomitanSession, + tryHandleOverlayShortcutLocalFallback: (input) => + overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { @@ -3584,9 +3637,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); +const yomitanProfilePolicy = createYomitanProfilePolicy({ + externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, + logInfo: (message) => logger.info(message), +}); +const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; const yomitanExtensionRuntime = createYomitanExtensionRuntime({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, + externalProfilePath: configuredExternalYomitanProfilePath, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; @@ -3600,6 +3659,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({ setYomitanExtension: (extension) => { appState.yomitanExt = extension; }, + setYomitanSession: (nextSession) => { + appState.yomitanSession = nextSession; + }, getYomitanExtension: () => appState.yomitanExt, getLoadInFlight: () => yomitanLoadInFlight, setLoadInFlight: (promise) => { @@ -3641,11 +3703,18 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = }); const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { + getYomitanSession: () => appState.yomitanSession, + openYomitanSettingsWindow: ({ + yomitanExt, + getExistingWindow, + setWindow, + yomitanSession, + }) => { openYomitanSettingsWindow({ yomitanExt: yomitanExt as Extension, getExistingWindow: () => getExistingWindow() as BrowserWindow | null, setWindow: (window) => setWindow(window as BrowserWindow | null), + yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession, onWindowClosed: () => { if (appState.yomitanParserWindow) { clearYomitanParserCachesForWindow(appState.yomitanParserWindow); diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts index 23e6cc1..74468f0 100644 --- a/src/main/runtime/app-runtime-main-deps.test.ts +++ b/src/main/runtime/app-runtime-main-deps.test.ts @@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => { const calls: string[] = []; let currentWindow: unknown = null; const extension = { id: 'ext' }; + const yomitanSession = { id: 'session' }; const deps = createBuildOpenYomitanSettingsMainDepsHandler({ ensureYomitanExtensionLoaded: async () => extension, - openYomitanSettingsWindow: ({ yomitanExt }) => - calls.push(`open:${(yomitanExt as { id: string }).id}`), + openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) => + calls.push( + `open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`, + ), getExistingWindow: () => currentWindow, setWindow: (window) => { currentWindow = window; calls.push('set-window'); }, + getYomitanSession: () => yomitanSession, logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), })(); @@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => { yomitanExt: extension, getExistingWindow: () => deps.getExistingWindow(), setWindow: (window) => deps.setWindow(window), + yomitanSession: deps.getYomitanSession(), }); deps.logWarn('warn'); deps.logError('error', new Error('boom')); - assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']); + assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']); assert.deepEqual(currentWindow, { id: 'win' }); }); diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts index feeed18..5dd5d57 100644 --- a/src/main/runtime/app-runtime-main-deps.ts +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + yomitanSession?: unknown | null; onWindowClosed?: () => void; }) => void; getExistingWindow: () => TWindow | null; setWindow: (window: TWindow | null) => void; + getYomitanSession?: () => unknown | null; logWarn: (message: string) => void; logError: (message: string, error: unknown) => void; }) { @@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + yomitanSession?: unknown | null; onWindowClosed?: () => void; }) => deps.openYomitanSettingsWindow(params), getExistingWindow: () => deps.getExistingWindow(), setWindow: (window: TWindow | null) => deps.setWindow(window), + getYomitanSession: () => deps.getYomitanSession?.() ?? null, logWarn: (message: string) => deps.logWarn(message), logError: (message: string, error: unknown) => deps.logError(message, error), }); diff --git a/src/main/runtime/character-dictionary-availability.test.ts b/src/main/runtime/character-dictionary-availability.test.ts new file mode 100644 index 0000000..77a3aa4 --- /dev/null +++ b/src/main/runtime/character-dictionary-availability.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getCharacterDictionaryDisabledReason, + isCharacterDictionaryRuntimeEnabled, +} from './character-dictionary-availability'; + +test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => { + assert.equal(isCharacterDictionaryRuntimeEnabled(''), true); + assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true); + assert.equal(getCharacterDictionaryDisabledReason(''), null); +}); + +test('character dictionary runtime is disabled when external Yomitan profile is configured', () => { + assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false); + assert.equal( + getCharacterDictionaryDisabledReason('/tmp/gsm-profile'), + 'Character dictionary is disabled while yomitan.externalProfilePath is configured.', + ); +}); diff --git a/src/main/runtime/character-dictionary-availability.ts b/src/main/runtime/character-dictionary-availability.ts new file mode 100644 index 0000000..4d74f6b --- /dev/null +++ b/src/main/runtime/character-dictionary-availability.ts @@ -0,0 +1,10 @@ +export function isCharacterDictionaryRuntimeEnabled(externalProfilePath: string): boolean { + return externalProfilePath.trim().length === 0; +} + +export function getCharacterDictionaryDisabledReason(externalProfilePath: string): string | null { + if (isCharacterDictionaryRuntimeEnabled(externalProfilePath)) { + return null; + } + return 'Character dictionary is disabled while yomitan.externalProfilePath is configured.'; +} diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index ef7d0ce..af59fe1 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support const completed = await service.markSetupCompleted(); assert.equal(completed.state.status, 'completed'); assert.equal(completed.state.completionSource, 'user'); + assert.equal(completed.state.yomitanSetupMode, 'internal'); + }); +}); + +test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const initial = await service.ensureSetupStateInitialized(); + assert.equal(initial.canFinish, true); + + const completed = await service.markSetupCompleted(); + assert.equal(completed.state.status, 'completed'); + assert.equal(completed.state.yomitanSetupMode, 'external'); + assert.equal(completed.dictionaryCount, 0); + }); +}); + +test('setup service does not probe internal dictionaries when external yomitan is configured', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => { + throw new Error('should not probe internal dictionaries in external mode'); + }, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await service.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(snapshot.canFinish, true); + assert.equal(snapshot.externalYomitanConfigured, true); + assert.equal(snapshot.dictionaryCount, 0); + }); +}); + +test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + await service.ensureSetupStateInitialized(); + await service.markSetupCompleted(); + + const relaunched = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => false, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await relaunched.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'incomplete'); + assert.equal(snapshot.state.yomitanSetupMode, null); + assert.equal(snapshot.canFinish, false); + }); +}); + +test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const service = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 0, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + await service.ensureSetupStateInitialized(); + await service.markSetupCompleted(); + + const relaunched = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 2, + isExternalYomitanConfigured: () => false, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + onStateChanged: () => undefined, + }); + + const snapshot = await relaunched.ensureSetupStateInitialized(); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(snapshot.canFinish, true); }); }); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 5541d7f..49f6d23 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -26,6 +26,7 @@ export interface SetupStatusSnapshot { configReady: boolean; dictionaryCount: number; canFinish: boolean; + externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot; @@ -139,10 +140,50 @@ function getEffectiveWindowsMpvShortcutPreferences( }; } +function isYomitanSetupSatisfied(options: { + configReady: boolean; + dictionaryCount: number; + externalYomitanConfigured: boolean; +}): boolean { + if (!options.configReady) { + return false; + } + return options.externalYomitanConfigured || options.dictionaryCount >= 1; +} + +async function resolveYomitanSetupStatus(deps: { + configFilePaths: { jsoncPath: string; jsonPath: string }; + getYomitanDictionaryCount: () => Promise; + isExternalYomitanConfigured?: () => boolean; +}): Promise<{ + configReady: boolean; + dictionaryCount: number; + externalYomitanConfigured: boolean; +}> { + const configReady = + fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath); + const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false; + + if (configReady && externalYomitanConfigured) { + return { + configReady, + dictionaryCount: 0, + externalYomitanConfigured, + }; + } + + return { + configReady, + dictionaryCount: await deps.getYomitanDictionaryCount(), + externalYomitanConfigured, + }; +} + export function createFirstRunSetupService(deps: { platform?: NodeJS.Platform; configDir: string; getYomitanDictionaryCount: () => Promise; + isExternalYomitanConfigured?: () => boolean; detectPluginInstalled: () => boolean | Promise; installPlugin: () => Promise; detectWindowsMpvShortcuts?: () => @@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: { }; const buildSnapshot = async (state: SetupState, message: string | null = null) => { - const dictionaryCount = await deps.getYomitanDictionaryCount(); + const { configReady, dictionaryCount, externalYomitanConfigured } = + await resolveYomitanSetupStatus({ + configFilePaths, + getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + isExternalYomitanConfigured: deps.isExternalYomitanConfigured, + }); const pluginInstalled = await deps.detectPluginInstalled(); const detectedWindowsMpvShortcuts = isWindows ? await deps.detectWindowsMpvShortcuts?.() @@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: { state, installedWindowsMpvShortcuts, ); - const configReady = - fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); return { configReady, dictionaryCount, - canFinish: dictionaryCount >= 1, + canFinish: isYomitanSetupSatisfied({ + configReady, + dictionaryCount, + externalYomitanConfigured, + }), + externalYomitanConfigured, pluginStatus: getPluginStatus(state, pluginInstalled), pluginInstallPathSummary: state.pluginInstallPathSummary, windowsMpvShortcuts: { @@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: { return { ensureSetupStateInitialized: async () => { const state = readState(); - if (isSetupCompleted(state)) { + const { configReady, dictionaryCount, externalYomitanConfigured } = + await resolveYomitanSetupStatus({ + configFilePaths, + getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + isExternalYomitanConfigured: deps.isExternalYomitanConfigured, + }); + const yomitanSetupSatisfied = isYomitanSetupSatisfied({ + configReady, + dictionaryCount, + externalYomitanConfigured, + }); + if ( + isSetupCompleted(state) && + !(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied) + ) { completed = true; return refreshWithState(state); } - const dictionaryCount = await deps.getYomitanDictionaryCount(); - const configReady = - fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath); - if (configReady && dictionaryCount >= 1) { + if (yomitanSetupSatisfied) { const completedState = writeState({ ...state, status: 'completed', completedAt: new Date().toISOString(), completionSource: 'legacy_auto_detected', + yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: dictionaryCount, }); return buildSnapshot(completedState); @@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: { status: state.status === 'cancelled' ? 'cancelled' : 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: dictionaryCount, }), ); @@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: { status: 'completed', completedAt: new Date().toISOString(), completionSource: 'user', + yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal', lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, }), ); diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 34b300b..6c631f2 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish configReady: true, dictionaryCount: 0, canFinish: false, + externalYomitanConfigured: false, pluginStatus: 'optional', pluginInstallPathSummary: null, windowsMpvShortcuts: { @@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in configReady: true, dictionaryCount: 1, canFinish: true, + externalYomitanConfigured: false, pluginStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', windowsMpvShortcuts: { @@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in assert.match(html, /Reinstall mpv plugin/); }); +test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => { + const html = buildFirstRunSetupHtml({ + configReady: true, + dictionaryCount: 0, + canFinish: true, + externalYomitanConfigured: true, + pluginStatus: 'optional', + pluginInstallPathSummary: null, + windowsMpvShortcuts: { + supported: false, + startMenuEnabled: true, + desktopEnabled: true, + startMenuInstalled: false, + desktopInstalled: false, + status: 'optional', + }, + message: null, + }); + + assert.match(html, /External profile configured/); + assert.match( + html, + /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./, + ); +}); + test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { action: 'refresh', @@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy configReady: false, dictionaryCount: 0, canFinish: false, + externalYomitanConfigured: false, pluginStatus: 'optional', pluginInstallPathSummary: null, windowsMpvShortcuts: { diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index d6682bd..d401e98 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel { configReady: boolean; dictionaryCount: number; canFinish: boolean; + externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginInstallPathSummary: string | null; windowsMpvShortcuts: { @@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ` : ''; + const yomitanMeta = model.externalYomitanConfigured + ? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.' + : `${model.dictionaryCount} installed`; + const yomitanBadgeLabel = model.externalYomitanConfigured + ? 'External' + : model.dictionaryCount >= 1 + ? 'Ready' + : 'Missing'; + const yomitanBadgeTone = model.externalYomitanConfigured + ? 'ready' + : model.dictionaryCount >= 1 + ? 'ready' + : 'warn'; + const footerMessage = model.externalYomitanConfigured + ? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.' + : 'Finish stays locked until Yomitan reports at least one installed dictionary.'; + return ` @@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
Yomitan dictionaries -
${model.dictionaryCount} installed
+
${escapeHtml(yomitanMeta)}
- ${renderStatusBadge( - model.dictionaryCount >= 1 ? 'Ready' : 'Missing', - model.dictionaryCount >= 1 ? 'ready' : 'warn', - )} + ${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
${windowsShortcutCard}
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
${model.message ? escapeHtml(model.message) : ''}
- + `; diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts index 11d73af..49e48f0 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.test.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -8,6 +8,7 @@ import { test('overlay window factory main deps builders return mapped handlers', () => { const calls: string[] = []; + const yomitanSession = { id: 'session' } as never; const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({ createOverlayWindowCore: (kind) => ({ kind }), isDev: true, @@ -18,11 +19,13 @@ test('overlay window factory main deps builders return mapped handlers', () => { tryHandleOverlayShortcutLocalFallback: () => false, forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), + getYomitanSession: () => yomitanSession, }); const overlayDeps = buildOverlayDeps(); assert.equal(overlayDeps.isDev, true); assert.equal(overlayDeps.isOverlayVisible('visible'), true); + assert.equal(overlayDeps.getYomitanSession(), yomitanSession); overlayDeps.forwardTabToMpv(); const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index 8475ce7..881289d 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -1,3 +1,5 @@ +import type { Session } from 'electron'; + export function createBuildCreateOverlayWindowMainDepsHandler(deps: { createOverlayWindowCore: ( kind: 'visible' | 'modal', @@ -10,6 +12,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; + yomitanSession?: Session | null; }, ) => TWindow; isDev: boolean; @@ -20,6 +23,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; + getYomitanSession?: () => Session | null; }) { return () => ({ createOverlayWindowCore: deps.createOverlayWindowCore, @@ -31,6 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, onWindowClosed: deps.onWindowClosed, + getYomitanSession: () => deps.getYomitanSession?.() ?? null, }); } diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts index f06a29b..85f98c7 100644 --- a/src/main/runtime/overlay-window-factory.test.ts +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -9,12 +9,14 @@ import { test('create overlay window handler forwards options and kind', () => { const calls: string[] = []; const window = { id: 1 }; + const yomitanSession = { id: 'session' } as never; const createOverlayWindow = createCreateOverlayWindowHandler({ createOverlayWindowCore: (kind, options) => { calls.push(`kind:${kind}`); assert.equal(options.isDev, true); assert.equal(options.isOverlayVisible('visible'), true); assert.equal(options.isOverlayVisible('modal'), false); + assert.equal(options.yomitanSession, yomitanSession); options.forwardTabToMpv(); options.onRuntimeOptionsChanged(); options.setOverlayDebugVisualizationEnabled(true); @@ -29,6 +31,7 @@ test('create overlay window handler forwards options and kind', () => { tryHandleOverlayShortcutLocalFallback: () => false, forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), + getYomitanSession: () => yomitanSession, }); assert.equal(createOverlayWindow('visible'), window); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index 9428264..9219ad1 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -1,3 +1,5 @@ +import type { Session } from 'electron'; + type OverlayWindowKind = 'visible' | 'modal'; export function createCreateOverlayWindowHandler(deps: { @@ -12,6 +14,7 @@ export function createCreateOverlayWindowHandler(deps: { tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; + yomitanSession?: Session | null; }, ) => TWindow; isDev: boolean; @@ -22,6 +25,7 @@ export function createCreateOverlayWindowHandler(deps: { tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; + getYomitanSession?: () => Session | null; }) { return (kind: OverlayWindowKind): TWindow => { return deps.createOverlayWindowCore(kind, { @@ -33,6 +37,7 @@ export function createCreateOverlayWindowHandler(deps: { tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, onWindowClosed: deps.onWindowClosed, + yomitanSession: deps.getYomitanSession?.() ?? null, }); }; } diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts index d7b43d6..39b4711 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.test.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -7,10 +7,14 @@ test('overlay window runtime handlers compose create/main/modal handlers', () => let modalWindow: { kind: string } | null = null; let debugEnabled = false; const calls: string[] = []; + const yomitanSession = { id: 'session' } as never; - const runtime = createOverlayWindowRuntimeHandlers({ + const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({ createOverlayWindowDeps: { - createOverlayWindowCore: (kind) => ({ kind }), + createOverlayWindowCore: (kind, options) => { + assert.equal(options.yomitanSession, yomitanSession); + return { kind }; + }, isDev: true, ensureOverlayWindowLevel: () => calls.push('ensure-level'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), @@ -21,6 +25,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () => tryHandleOverlayShortcutLocalFallback: () => false, forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), + getYomitanSession: () => yomitanSession, }, setMainWindow: (window) => { mainWindow = window; diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index 607ea40..1e27d19 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { return (): TokenizerDepsRuntimeOptions => ({ getYomitanExt: () => deps.getYomitanExt(), + getYomitanSession: () => deps.getYomitanSession?.() ?? null, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts index da97222..d30440d 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts @@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => { return null; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => null, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), })(); assert.equal(deps.userDataPath, '/tmp/subminer'); + assert.equal(deps.externalProfilePath, '/tmp/gsm-profile'); await deps.loadYomitanExtensionCore({} as never); deps.setYomitanParserWindow(null); deps.setYomitanParserReadyPromise(null); deps.setYomitanParserInitPromise(null); deps.setYomitanExtension(null); - assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']); + deps.setYomitanSession(null as never); + assert.deepEqual(calls, [ + 'load-core', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan extension loaded main deps builder maps callbacks', async () => { diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.ts b/src/main/runtime/yomitan-extension-loader-main-deps.ts index 7acb73f..0980b15 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.ts @@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan return (): LoadYomitanExtensionMainDeps => ({ loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options), userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise), setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise), setYomitanExtension: (extension) => deps.setYomitanExtension(extension), + setYomitanSession: (session) => deps.setYomitanSession(session), }); } diff --git a/src/main/runtime/yomitan-extension-loader.test.ts b/src/main/runtime/yomitan-extension-loader.test.ts index 5de56fa..87fa053 100644 --- a/src/main/runtime/yomitan-extension-loader.test.ts +++ b/src/main/runtime/yomitan-extension-loader.test.ts @@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async const loadYomitanExtension = createLoadYomitanExtensionHandler({ loadYomitanExtensionCore: async (options) => { calls.push(`path:${options.userDataPath}`); + calls.push(`external:${options.externalProfilePath ?? ''}`); assert.equal(options.getYomitanParserWindow(), parserWindow); options.setYomitanParserWindow(null); options.setYomitanParserReadyPromise(null); options.setYomitanParserInitPromise(null); options.setYomitanExtension(extension); + options.setYomitanSession(null); return extension; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => parserWindow, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), }); assert.equal(await loadYomitanExtension(), extension); - assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']); + assert.deepEqual(calls, [ + 'path:/tmp/subminer', + 'external:/tmp/gsm-profile', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan loader returns existing extension when available', async () => { diff --git a/src/main/runtime/yomitan-extension-loader.ts b/src/main/runtime/yomitan-extension-loader.ts index e668e8c..23a7b60 100644 --- a/src/main/runtime/yomitan-extension-loader.ts +++ b/src/main/runtime/yomitan-extension-loader.ts @@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext export function createLoadYomitanExtensionHandler(deps: { loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; userDataPath: YomitanExtensionLoaderDeps['userDataPath']; + externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath']; getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; + setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession']; }) { return async (): Promise => { return deps.loadYomitanExtensionCore({ userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); }; } diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index bc0d26e..7139869 100644 --- a/src/main/runtime/yomitan-extension-runtime.test.ts +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -9,6 +9,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after let parserWindow: unknown = null; let readyPromise: Promise | null = null; let initPromise: Promise | null = null; + let yomitanSession: unknown = null; + let receivedExternalProfilePath = ''; let loadCalls = 0; const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { releaseLoad: null, @@ -17,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after const runtime = createYomitanExtensionRuntime({ loadYomitanExtensionCore: async (options) => { loadCalls += 1; + receivedExternalProfilePath = options.externalProfilePath ?? ''; options.setYomitanParserWindow(null); options.setYomitanParserReadyPromise(Promise.resolve()); options.setYomitanParserInitPromise(Promise.resolve(true)); + options.setYomitanSession({ id: 'session' } as never); return await new Promise((resolve) => { releaseLoadState.releaseLoad = (value) => { options.setYomitanExtension(value); @@ -28,6 +32,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after }); }, userDataPath: '/tmp', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => parserWindow as never, setYomitanParserWindow: (window) => { parserWindow = window; @@ -41,6 +46,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after setYomitanExtension: (next) => { extension = next; }, + setYomitanSession: (next) => { + yomitanSession = next; + }, getYomitanExtension: () => extension, getLoadInFlight: () => inFlight, setLoadInFlight: (promise) => { @@ -55,6 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after assert.equal(parserWindow, null); assert.ok(readyPromise); assert.ok(initPromise); + assert.deepEqual(yomitanSession, { id: 'session' }); + assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile'); const fakeExtension = { id: 'yomitan' } as Extension; const releaseLoad = releaseLoadState.releaseLoad; @@ -74,18 +84,26 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after test('yomitan extension runtime direct load delegates to core', async () => { let loadCalls = 0; + let receivedExternalProfilePath = ''; + let yomitanSession: unknown = null; const runtime = createYomitanExtensionRuntime({ - loadYomitanExtensionCore: async () => { + loadYomitanExtensionCore: async (options) => { loadCalls += 1; + receivedExternalProfilePath = options.externalProfilePath ?? ''; + options.setYomitanSession({ id: 'session' } as never); return null; }, userDataPath: '/tmp', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => null, setYomitanParserWindow: () => {}, setYomitanParserReadyPromise: () => {}, setYomitanParserInitPromise: () => {}, setYomitanExtension: () => {}, + setYomitanSession: (next) => { + yomitanSession = next; + }, getYomitanExtension: () => null, getLoadInFlight: () => null, setLoadInFlight: () => {}, @@ -93,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => { assert.equal(await runtime.loadYomitanExtension(), null); assert.equal(loadCalls, 1); + assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile'); + assert.deepEqual(yomitanSession, { id: 'session' }); }); diff --git a/src/main/runtime/yomitan-extension-runtime.ts b/src/main/runtime/yomitan-extension-runtime.ts index 7ecf9ad..60093a6 100644 --- a/src/main/runtime/yomitan-extension-runtime.ts +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ loadYomitanExtensionCore: deps.loadYomitanExtensionCore, userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler( buildLoadYomitanExtensionMainDepsHandler(), diff --git a/src/main/runtime/yomitan-profile-policy.test.ts b/src/main/runtime/yomitan-profile-policy.test.ts new file mode 100644 index 0000000..0d7be2c --- /dev/null +++ b/src/main/runtime/yomitan-profile-policy.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createYomitanProfilePolicy } from './yomitan-profile-policy'; + +test('yomitan profile policy trims external profile path and marks read-only mode', () => { + const calls: string[] = []; + const policy = createYomitanProfilePolicy({ + externalProfilePath: ' /tmp/gsm-profile ', + logInfo: (message) => calls.push(message), + }); + + assert.equal(policy.externalProfilePath, '/tmp/gsm-profile'); + assert.equal(policy.isExternalReadOnlyMode(), true); + assert.equal(policy.isCharacterDictionaryEnabled(), false); + assert.equal( + policy.getCharacterDictionaryDisabledReason(), + 'Character dictionary is disabled while yomitan.externalProfilePath is configured.', + ); + + policy.logSkippedWrite('importYomitanDictionary(sample.zip)'); + assert.deepEqual(calls, [ + '[yomitan] skipping importYomitanDictionary(sample.zip): yomitan.externalProfilePath is configured; external profile mode is read-only', + ]); +}); + +test('yomitan profile policy keeps character dictionary enabled without external profile path', () => { + const policy = createYomitanProfilePolicy({ + externalProfilePath: ' ', + logInfo: () => undefined, + }); + + assert.equal(policy.externalProfilePath, ''); + assert.equal(policy.isExternalReadOnlyMode(), false); + assert.equal(policy.isCharacterDictionaryEnabled(), true); + assert.equal(policy.getCharacterDictionaryDisabledReason(), null); +}); diff --git a/src/main/runtime/yomitan-profile-policy.ts b/src/main/runtime/yomitan-profile-policy.ts new file mode 100644 index 0000000..7958bfd --- /dev/null +++ b/src/main/runtime/yomitan-profile-policy.ts @@ -0,0 +1,25 @@ +import { + getCharacterDictionaryDisabledReason, + isCharacterDictionaryRuntimeEnabled, +} from './character-dictionary-availability'; + +export function createYomitanProfilePolicy(options: { + externalProfilePath: string; + logInfo: (message: string) => void; +}) { + const externalProfilePath = options.externalProfilePath.trim(); + + return { + externalProfilePath, + isExternalReadOnlyMode: (): boolean => externalProfilePath.length > 0, + isCharacterDictionaryEnabled: (): boolean => + isCharacterDictionaryRuntimeEnabled(externalProfilePath), + getCharacterDictionaryDisabledReason: (): string | null => + getCharacterDictionaryDisabledReason(externalProfilePath), + logSkippedWrite: (action: string): void => { + options.logInfo( + `[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`, + ); + }, + }; +} diff --git a/src/main/runtime/yomitan-read-only-log.test.ts b/src/main/runtime/yomitan-read-only-log.test.ts new file mode 100644 index 0000000..41ca286 --- /dev/null +++ b/src/main/runtime/yomitan-read-only-log.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatSkippedYomitanWriteAction } from './yomitan-read-only-log'; + +test('formatSkippedYomitanWriteAction redacts full filesystem paths to basenames', () => { + assert.equal( + formatSkippedYomitanWriteAction('importYomitanDictionary', '/tmp/private/merged.zip'), + 'importYomitanDictionary(merged.zip)', + ); +}); + +test('formatSkippedYomitanWriteAction redacts dictionary titles', () => { + assert.equal( + formatSkippedYomitanWriteAction('deleteYomitanDictionary', 'SubMiner Character Dictionary'), + 'deleteYomitanDictionary()', + ); +}); + +test('formatSkippedYomitanWriteAction falls back when value is blank', () => { + assert.equal( + formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '), + 'upsertYomitanDictionarySettings()', + ); +}); diff --git a/src/main/runtime/yomitan-read-only-log.ts b/src/main/runtime/yomitan-read-only-log.ts new file mode 100644 index 0000000..71816e5 --- /dev/null +++ b/src/main/runtime/yomitan-read-only-log.ts @@ -0,0 +1,25 @@ +import * as path from 'path'; + +function redactSkippedYomitanWriteValue( + actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings', + rawValue: string, +): string { + const trimmed = rawValue.trim(); + if (!trimmed) { + return ''; + } + + if (actionName === 'importYomitanDictionary') { + const basename = path.basename(trimmed); + return basename || ''; + } + + return ''; +} + +export function formatSkippedYomitanWriteAction( + actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings', + rawValue: string, +): string { + return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`; +} diff --git a/src/main/runtime/yomitan-settings-opener.test.ts b/src/main/runtime/yomitan-settings-opener.test.ts index 2bc9728..6cdb52d 100644 --- a/src/main/runtime/yomitan-settings-opener.test.ts +++ b/src/main/runtime/yomitan-settings-opener.test.ts @@ -22,14 +22,16 @@ test('yomitan opener warns when extension cannot be loaded', async () => { }); test('yomitan opener opens settings window when extension is available', async () => { - let opened = false; + let forwardedSession: { id: string } | null | undefined; + const yomitanSession = { id: 'session' }; const openSettings = createOpenYomitanSettingsHandler({ ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), - openYomitanSettingsWindow: () => { - opened = true; + openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => { + forwardedSession = nextSession as { id: string } | null; }, getExistingWindow: () => null, setWindow: () => {}, + getYomitanSession: () => yomitanSession, logWarn: () => {}, logError: () => {}, }); @@ -37,5 +39,5 @@ test('yomitan opener opens settings window when extension is available', async ( openSettings(); await Promise.resolve(); await Promise.resolve(); - assert.equal(opened, true); + assert.equal(forwardedSession, yomitanSession); }); diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts index 85339b6..2695320 100644 --- a/src/main/runtime/yomitan-settings-opener.ts +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -1,5 +1,6 @@ type YomitanExtensionLike = unknown; type BrowserWindowLike = unknown; +type SessionLike = unknown; export function createOpenYomitanSettingsHandler(deps: { ensureYomitanExtensionLoaded: () => Promise; @@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: { yomitanExt: YomitanExtensionLike; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; + yomitanSession?: SessionLike | null; onWindowClosed?: () => void; }) => void; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; + getYomitanSession?: () => SessionLike | null; logWarn: (message: string) => void; logError: (message: string, error: unknown) => void; }) { @@ -21,10 +24,16 @@ export function createOpenYomitanSettingsHandler(deps: { deps.logWarn('Unable to open Yomitan settings: extension failed to load.'); return; } + const yomitanSession = deps.getYomitanSession?.() ?? null; + if (!yomitanSession) { + deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.'); + return; + } deps.openYomitanSettingsWindow({ yomitanExt: extension, getExistingWindow: deps.getExistingWindow, setWindow: deps.setWindow, + yomitanSession, }); })().catch((error) => { deps.logError('Failed to open Yomitan settings window.', error); diff --git a/src/main/runtime/yomitan-settings-runtime.test.ts b/src/main/runtime/yomitan-settings-runtime.test.ts index f5b843b..db93ac6 100644 --- a/src/main/runtime/yomitan-settings-runtime.test.ts +++ b/src/main/runtime/yomitan-settings-runtime.test.ts @@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime'; test('yomitan settings runtime composes opener with built deps', async () => { let existingWindow: { id: string } | null = null; const calls: string[] = []; + const yomitanSession = { id: 'session' }; const runtime = createYomitanSettingsRuntime({ ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), - openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => { - calls.push('open-window'); + openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => { + calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`); const current = getExistingWindow(); if (!current) { setWindow({ id: 'settings' }); @@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => { setWindow: (window) => { existingWindow = window as { id: string } | null; }, + getYomitanSession: () => yomitanSession, logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), }); @@ -27,5 +29,30 @@ test('yomitan settings runtime composes opener with built deps', async () => { await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(existingWindow, { id: 'settings' }); - assert.deepEqual(calls, ['open-window']); + assert.deepEqual(calls, ['open-window:session']); +}); + +test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => { + let existingWindow: { id: string } | null = null; + const calls: string[] = []; + + const runtime = createYomitanSettingsRuntime({ + ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), + openYomitanSettingsWindow: () => { + calls.push('open-window'); + }, + getExistingWindow: () => existingWindow as never, + setWindow: (window) => { + existingWindow = window as { id: string } | null; + }, + getYomitanSession: () => null, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + runtime.openYomitanSettings(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(existingWindow, null); + assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']); }); diff --git a/src/main/state.ts b/src/main/state.ts index ca2679e..6dd67a7 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import type { Keybinding, @@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState( export interface AppState { yomitanExt: Extension | null; + yomitanSession: Session | null; yomitanSettingsWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null; @@ -219,6 +220,7 @@ export interface StartupState { export function createAppState(values: AppStateInitialValues): AppState { return { yomitanExt: null, + yomitanSession: null, yomitanSettingsWindow: null, yomitanParserWindow: null, anilistSetupWindow: null, diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index 6ef45a5..80d5725 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -65,7 +65,7 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe }); }); -test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => { +test('ensureDefaultConfigBootstrap seeds default config into an existing config directory when missing', () => { withTempDir((root) => { const configDir = path.join(root, 'SubMiner'); fs.mkdirSync(configDir, { recursive: true }); @@ -74,10 +74,13 @@ test('ensureDefaultConfigBootstrap does not seed default config into an existing ensureDefaultConfigBootstrap({ configDir, configFilePaths: getDefaultConfigFilePaths(configDir), - generateTemplate: () => 'should-not-write', + generateTemplate: () => '{\n "logging": {}\n}\n', }); - assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false); + assert.equal( + fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'), + '{\n "logging": {}\n}\n', + ); assert.equal(fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'), 'keep\n'); }); }); @@ -91,6 +94,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => { const state = createDefaultSetupState(); state.status = 'completed'; state.completionSource = 'user'; + state.yomitanSetupMode = 'internal'; state.lastSeenYomitanDictionaryCount = 2; writeSetupState(statePath, state); @@ -98,7 +102,7 @@ test('readSetupState ignores invalid files and round-trips valid state', () => { }); }); -test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { +test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => { withTempDir((root) => { const statePath = getSetupStatePath(root); fs.writeFileSync( @@ -115,10 +119,11 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { ); assert.deepEqual(readSetupState(statePath), { - version: 2, + version: 3, status: 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -131,6 +136,45 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => { }); }); +test('readSetupState migrates completed v2 state to internal yomitan setup mode', () => { + withTempDir((root) => { + const statePath = getSetupStatePath(root); + fs.writeFileSync( + statePath, + JSON.stringify({ + version: 2, + status: 'completed', + completedAt: '2026-03-12T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { + startMenuEnabled: true, + desktopEnabled: true, + }, + windowsMpvShortcutLastStatus: 'unknown', + }), + ); + + assert.deepEqual(readSetupState(statePath), { + version: 3, + status: 'completed', + completedAt: '2026-03-12T00:00:00.000Z', + completionSource: 'user', + yomitanSetupMode: 'internal', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { + startMenuEnabled: true, + desktopEnabled: true, + }, + windowsMpvShortcutLastStatus: 'unknown', + }); + }); +}); + test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => { const linuxHomeDir = path.join(path.sep, 'tmp', 'home'); const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg'); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index c82cc99..ae13149 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -5,6 +5,7 @@ import { resolveConfigDir } from '../config/path-resolution'; export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled'; export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null; +export type SetupYomitanMode = 'internal' | 'external' | null; export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; @@ -14,10 +15,11 @@ export interface SetupWindowsMpvShortcutPreferences { } export interface SetupState { - version: 2; + version: 3; status: SetupStateStatus; completedAt: string | null; completionSource: SetupCompletionSource; + yomitanSetupMode: SetupYomitanMode; lastSeenYomitanDictionaryCount: number; pluginInstallStatus: SetupPluginInstallStatus; pluginInstallPathSummary: string | null; @@ -52,10 +54,11 @@ function asObject(value: unknown): Record | null { export function createDefaultSetupState(): SetupState { return { - version: 2, + version: 3, status: 'incomplete', completedAt: null, completionSource: null, + yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0, pluginInstallStatus: 'unknown', pluginInstallPathSummary: null, @@ -74,11 +77,12 @@ export function normalizeSetupState(value: unknown): SetupState | null { const status = record.status; const pluginInstallStatus = record.pluginInstallStatus; const completionSource = record.completionSource; + const yomitanSetupMode = record.yomitanSetupMode; const windowsPrefs = asObject(record.windowsMpvShortcutPreferences); const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus; if ( - (version !== 1 && version !== 2) || + (version !== 1 && version !== 2 && version !== 3) || (status !== 'incomplete' && status !== 'in_progress' && status !== 'completed' && @@ -94,16 +98,26 @@ export function normalizeSetupState(value: unknown): SetupState | null { windowsMpvShortcutLastStatus !== 'failed') || (completionSource !== null && completionSource !== 'user' && - completionSource !== 'legacy_auto_detected') + completionSource !== 'legacy_auto_detected') || + (version === 3 && + yomitanSetupMode !== null && + yomitanSetupMode !== 'internal' && + yomitanSetupMode !== 'external') ) { return null; } return { - version: 2, + version: 3, status, completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, completionSource, + yomitanSetupMode: + version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external') + ? yomitanSetupMode + : status === 'completed' + ? 'internal' + : null, lastSeenYomitanDictionaryCount: typeof record.lastSeenYomitanDictionaryCount === 'number' && Number.isFinite(record.lastSeenYomitanDictionaryCount) && @@ -208,13 +222,8 @@ export function ensureDefaultConfigBootstrap(options: { const existsSync = options.existsSync ?? fs.existsSync; const mkdirSync = options.mkdirSync ?? fs.mkdirSync; const writeFileSync = options.writeFileSync ?? fs.writeFileSync; - const configDirExists = existsSync(options.configDir); - if ( - existsSync(options.configFilePaths.jsoncPath) || - existsSync(options.configFilePaths.jsonPath) || - configDirExists - ) { + if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) { return; } diff --git a/src/types.ts b/src/types.ts index 36cd60b..f1aa764 100644 --- a/src/types.ts +++ b/src/types.ts @@ -501,6 +501,10 @@ export interface AnilistConfig { characterDictionary?: AnilistCharacterDictionaryConfig; } +export interface YomitanConfig { + externalProfilePath?: string; +} + export interface JellyfinConfig { enabled?: boolean; serverUrl?: string; @@ -585,6 +589,7 @@ export interface Config { auto_start_overlay?: boolean; jimaku?: JimakuConfig; anilist?: AnilistConfig; + yomitan?: YomitanConfig; jellyfin?: JellyfinConfig; discordPresence?: DiscordPresenceConfig; ai?: AiConfig; @@ -725,6 +730,9 @@ export interface ResolvedConfig { collapsibleSections: Required; }; }; + yomitan: { + externalProfilePath: string; + }; jellyfin: { enabled: boolean; serverUrl: string;