feat(yomitan): add read-only external profile support for shared dictionaries (#18)

This commit is contained in:
2026-03-12 01:17:34 -07:00
committed by GitHub
parent 68833c76c4
commit 1b56360a24
67 changed files with 1230 additions and 135 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # 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) ## v0.6.1 (2026-03-12)
### Added ### Added

View File

@@ -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.

View File

@@ -385,6 +385,17 @@
} // Character dictionary setting. } // Character dictionary setting.
}, // Anilist API credentials and update behavior. }, // 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 // Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch. // Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## v0.6.0 (2026-03-12)
- Added Chrome Gamepad API controller support for keyboard-only overlay mode. - 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. - Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.

View File

@@ -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. 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 ## 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: A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:

View File

@@ -113,6 +113,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates - [**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 - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**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-setup`: open AniList setup/auth flow helper window.
- `--anilist-retry-queue`: process one ready retry queue item immediately. - `--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
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. 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.

View File

@@ -385,6 +385,17 @@
} // Character dictionary setting. } // Character dictionary setting.
}, // Anilist API credentials and update behavior. }, // 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 // Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch. // Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -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". - 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. - 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. - If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
## MeCab / Tokenization ## MeCab / Tokenization

View File

@@ -21,6 +21,7 @@ import {
getSetupStatePath, getSetupStatePath,
readSetupState, readSetupState,
} from '../../src/shared/setup-state.js'; } from '../../src/shared/setup-state.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500; const SETUP_POLL_INTERVAL_MS = 500;
@@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const statePath = getSetupStatePath(configDir); const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({ const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath), readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
launchSetupApp: () => { launchSetupApp: () => {
const setupArgs = ['--background', '--setup']; const setupArgs = ['--background', '--setup'];
if (args.logLevel) { if (args.logLevel) {

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { readExternalYomitanProfilePath } from './config.js';
import { import {
getPluginConfigCandidates, getPluginConfigCandidates,
parsePluginRuntimeConfigContent, parsePluginRuntimeConfigContent,
@@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
test('getDefaultSocketPath returns Windows named pipe default', () => { test('getDefaultSocketPath returns Windows named pipe default', () => {
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket'); assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
}); });
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: ' ~/.config/gsm_overlay ',
},
}),
'~/.config/gsm_overlay',
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: ' ',
},
}),
null,
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: null,
}),
null,
);
assert.equal(
readExternalYomitanProfilePath({
yomitan: {
externalProfilePath: 123,
},
} as never),
null,
);
});

View File

@@ -17,6 +17,19 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
export function readExternalYomitanProfilePath(root: Record<string, unknown> | null): string | null {
const yomitan =
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
? (root.yomitan as Record<string, unknown>)
: null;
const externalProfilePath = yomitan?.externalProfilePath;
if (typeof externalProfilePath !== 'string') {
return null;
}
const trimmed = externalProfilePath.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
const root = readLauncherMainConfigObject(); const root = readLauncherMainConfigObject();
if (!root) return {}; if (!root) return {};
@@ -29,6 +42,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
return parseLauncherJellyfinConfig(root); return parseLauncherJellyfinConfig(root);
} }
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
return readPluginRuntimeConfigValue(logLevel); return readPluginRuntimeConfigValue(logLevel);
} }

View File

@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
const sequence: Array<SetupState | null> = [ const sequence: Array<SetupState | null> = [
null, null,
{ {
version: 2, version: 3,
status: 'in_progress', status: 'in_progress',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0, lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown', pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
@@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
windowsMpvShortcutLastStatus: 'unknown', windowsMpvShortcutLastStatus: 'unknown',
}, },
{ {
version: 2, version: 3,
status: 'completed', status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z', completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user', completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1, lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped', pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
@@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null; if (reads === 1) return null;
if (reads === 2) { if (reads === 2) {
return { return {
version: 2, version: 3,
status: 'in_progress', status: 'in_progress',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0, lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown', pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
@@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
}; };
} }
return { return {
version: 2, version: 3,
status: 'completed', status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z', completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user', completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1, lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed', pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv', pluginInstallPathSummary: '/tmp/mpv',
@@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
assert.deepEqual(calls, ['launch']); assert.deepEqual(calls, ['launch']);
}); });
test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => {
const calls: string[] = [];
const ready = await ensureLauncherSetupReady({
readSetupState: () => null,
isExternalYomitanConfigured: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({ const result = await ensureLauncherSetupReady({
readSetupState: () => ({ readSetupState: () => ({
version: 2, version: 3,
status: 'cancelled', status: 'cancelled',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0, lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown', pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,

View File

@@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: {
export async function ensureLauncherSetupReady(deps: { export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null; readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
launchSetupApp: () => void; launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>; sleep: (ms: number) => Promise<void>;
now: () => number; now: () => number;
timeoutMs: number; timeoutMs: number;
pollIntervalMs: number; pollIntervalMs: number;
}): Promise<boolean> { }): Promise<boolean> {
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
if (isSetupCompleted(deps.readSetupState())) { if (isSetupCompleted(deps.readSetupState())) {
return true; return true;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.6.1", "version": "0.6.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -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.description, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
assert.equal(config.yomitan.externalProfilePath, '');
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);

View File

@@ -32,7 +32,7 @@ const {
startupWarmups, startupWarmups,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } = const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG; INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay, auto_start_overlay,
jimaku, jimaku,
anilist, anilist,
yomitan,
jellyfin, jellyfin,
discordPresence, discordPresence,
ai, ai,

View File

@@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick< export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig, ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen' | 'ankiConnect'
| 'jimaku'
| 'anilist'
| 'yomitan'
| 'jellyfin'
| 'discordPresence'
| 'ai'
| 'youtubeSubgen'
> = { > = {
ankiConnect: { ankiConnect: {
enabled: false, enabled: false,
@@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
}, },
}, },
yomitan: {
externalProfilePath: '',
},
jellyfin: { jellyfin: {
enabled: false, enabled: false,
serverUrl: '', serverUrl: '',

View File

@@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', ()
'ankiConnect.enabled', 'ankiConnect.enabled',
'anilist.characterDictionary.enabled', 'anilist.characterDictionary.enabled',
'anilist.characterDictionary.collapsibleSections.description', 'anilist.characterDictionary.collapsibleSections.description',
'yomitan.externalProfilePath',
'immersionTracking.enabled', 'immersionTracking.enabled',
]) { ]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`); assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
@@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () =>
'startupWarmups', 'startupWarmups',
'subtitleStyle', 'subtitleStyle',
'ankiConnect', 'ankiConnect',
'yomitan',
'immersionTracking', 'immersionTracking',
]; ];

View File

@@ -211,6 +211,13 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Open the Voiced by section by default in character dictionary glossary entries.', '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', path: 'jellyfin.enabled',
kind: 'boolean', kind: 'boolean',

View File

@@ -137,6 +137,16 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
], ],
key: 'anilist', 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', title: 'Jellyfin',
description: [ description: [

View File

@@ -1,6 +1,19 @@
import * as os from 'node:os';
import * as path from 'node:path';
import { ResolveContext } from './context'; import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared'; 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 { export function applyIntegrationConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; 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)) { if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled); const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) { if (enabled !== undefined) {

View File

@@ -1,5 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as os from 'node:os';
import * as path from 'node:path';
import { createResolveContext } from './context'; import { createResolveContext } from './context';
import { applyIntegrationConfig } from './integrations'; 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'), 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'),
);
});

View File

@@ -154,7 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true); assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
assert.equal(calls.includes('reloadConfig'), false); assert.equal(calls.includes('reloadConfig'), true);
assert.equal(calls.includes('getResolvedConfig'), false); assert.equal(calls.includes('getResolvedConfig'), false);
assert.equal(calls.includes('getConfigWarnings'), false); assert.equal(calls.includes('getConfigWarnings'), false);
assert.equal(calls.includes('setLogLevel:warn:config'), false); assert.equal(calls.includes('setLogLevel:warn:config'), false);
@@ -170,6 +170,8 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadYomitanExtension'), true); assert.equal(calls.includes('loadYomitanExtension'), true);
assert.equal(calls.includes('handleFirstRunSetup'), true); assert.equal(calls.includes('handleFirstRunSetup'), true);
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('reloadConfig'));
assert.ok(calls.indexOf('reloadConfig') < calls.indexOf('handleFirstRunSetup'));
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup')); assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
}); });

View File

@@ -1,11 +1,27 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import { buildOverlayWindowOptions } from './overlay-window-options';
import path from 'node:path';
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => { test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts'); const options = buildOverlayWindowOptions('visible', {
const source = fs.readFileSync(sourcePath, 'utf8'); 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);
}); });

View File

@@ -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}`],
},
};
}

View File

@@ -1,4 +1,4 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow, type Session } from 'electron';
import * as path from 'path'; import * as path from 'path';
import { WindowGeometry } from '../../types'; import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
@@ -7,6 +7,7 @@ import {
handleOverlayWindowBeforeInputEvent, handleOverlayWindowBeforeInputEvent,
type OverlayWindowKind, type OverlayWindowKind,
} from './overlay-window-input'; } from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>(); const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -78,33 +79,10 @@ export function createOverlayWindow(
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void; onWindowClosed: (kind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
}, },
): BrowserWindow { ): BrowserWindow {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
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}`],
},
});
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
loadOverlayWindowLayer(window, kind); loadOverlayWindowLayer(window, kind);
@@ -170,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
loadOverlayWindowLayer(window, layer); loadOverlayWindowLayer(window, layer);
} }
export { buildOverlayWindowOptions } from './overlay-window-options';
export type { OverlayWindowKind } from './overlay-window-input'; export type { OverlayWindowKind } from './overlay-window-input';

View File

@@ -185,6 +185,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.ensureDefaultConfigBootstrap(); deps.ensureDefaultConfigBootstrap();
if (deps.shouldSkipHeavyStartup?.()) { if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension(); await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup(); await deps.handleFirstRunSetup();
deps.handleInitialArgs(); deps.handleInitialArgs();
return; return;
@@ -194,6 +195,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) { if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension(); await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup(); await deps.handleFirstRunSetup();
deps.handleInitialArgs(); deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`); deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);

View File

@@ -1,4 +1,4 @@
import type { BrowserWindow, Extension } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import { mergeTokens } from '../../token-merger'; import { mergeTokens } from '../../token-merger';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { import {
@@ -33,6 +33,7 @@ type MecabTokenEnrichmentFn = (
export interface TokenizerServiceDeps { export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null; getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null; getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null; getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -63,6 +64,7 @@ interface MecabTokenizerLike {
export interface TokenizerDepsRuntimeOptions { export interface TokenizerDepsRuntimeOptions {
getYomitanExt: () => Extension | null; getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null; getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null; getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -182,6 +184,7 @@ export function createTokenizerDepsRuntime(
return { return {
getYomitanExt: options.getYomitanExt, getYomitanExt: options.getYomitanExt,
getYomitanSession: options.getYomitanSession,
getYomitanParserWindow: options.getYomitanParserWindow, getYomitanParserWindow: options.getYomitanParserWindow,
setYomitanParserWindow: options.setYomitanParserWindow, setYomitanParserWindow: options.setYomitanParserWindow,
getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, getYomitanParserReadyPromise: options.getYomitanParserReadyPromise,

View File

@@ -1,4 +1,4 @@
import type { BrowserWindow, Extension } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { selectYomitanParseTokens } from './parser-selection-stage'; import { selectYomitanParseTokens } from './parser-selection-stage';
@@ -10,6 +10,7 @@ interface LoggerLike {
interface YomitanParserRuntimeDeps { interface YomitanParserRuntimeDeps {
getYomitanExt: () => Extension | null; getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null; getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null; getYomitanParserReadyPromise: () => Promise<void> | null;
@@ -465,6 +466,7 @@ async function ensureYomitanParserWindow(
const initPromise = (async () => { const initPromise = (async () => {
const { BrowserWindow, session } = electron; const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const parserWindow = new BrowserWindow({ const parserWindow = new BrowserWindow({
show: false, show: false,
width: 800, width: 800,
@@ -472,7 +474,7 @@ async function ensureYomitanParserWindow(
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
session: session.defaultSession, session: yomitanSession,
}, },
}); });
deps.setYomitanParserWindow(parserWindow); deps.setYomitanParserWindow(parserWindow);
@@ -539,6 +541,7 @@ async function createYomitanExtensionWindow(
} }
const { BrowserWindow, session } = electron; const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const window = new BrowserWindow({ const window = new BrowserWindow({
show: false, show: false,
width: 1200, width: 1200,
@@ -546,7 +549,7 @@ async function createYomitanExtensionWindow(
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
session: session.defaultSession, session: yomitanSession,
}, },
}); });

View File

@@ -1,12 +1,18 @@
import electron from 'electron'; import electron from 'electron';
import type { BrowserWindow, Extension } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { ensureExtensionCopy } from './yomitan-extension-copy'; import { ensureExtensionCopy } from './yomitan-extension-copy';
import { import {
getYomitanExtensionSearchPaths, getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
resolveExistingYomitanExtensionPath, resolveExistingYomitanExtensionPath,
} from './yomitan-extension-paths'; } from './yomitan-extension-paths';
import {
clearYomitanExtensionRuntimeState,
clearYomitanParserRuntimeState,
} from './yomitan-extension-runtime-state';
const { session } = electron; const { session } = electron;
const logger = createLogger('main:yomitan-extension-loader'); const logger = createLogger('main:yomitan-extension-loader');
@@ -14,51 +20,82 @@ const logger = createLogger('main:yomitan-extension-loader');
export interface YomitanExtensionLoaderDeps { export interface YomitanExtensionLoaderDeps {
userDataPath: string; userDataPath: string;
extensionPath?: string; extensionPath?: string;
externalProfilePath?: string;
getYomitanParserWindow: () => BrowserWindow | null; getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserWindow: (window: BrowserWindow | null) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void; setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void; setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
setYomitanExtension: (extension: Extension | null) => void; setYomitanExtension: (extension: Extension | null) => void;
setYomitanSession: (session: Session | null) => void;
} }
export async function loadYomitanExtension( export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps, deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> { ): Promise<Extension | null> {
const searchPaths = getYomitanExtensionSearchPaths({ const clearRuntimeState = () =>
explicitPath: deps.extensionPath, clearYomitanExtensionRuntimeState({
moduleDir: __dirname, getYomitanParserWindow: deps.getYomitanParserWindow,
resourcesPath: process.resourcesPath, setYomitanParserWindow: deps.setYomitanParserWindow,
userDataPath: deps.userDataPath, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
}); setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync); 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) { if (externalProfilePath) {
logger.error('Yomitan extension not found in any search path'); const resolvedProfilePath = path.resolve(externalProfilePath);
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths); extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync);
return null; 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); clearParserState();
if (extensionCopy.copied) { deps.setYomitanSession(targetSession);
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);
try { try {
const extensions = session.defaultSession.extensions; const extensions = targetSession.extensions;
const extension = extensions const extension = extensions
? await extensions.loadExtension(extPath, { ? await extensions.loadExtension(extPath, {
allowFileAccess: true, allowFileAccess: true,
}) })
: await session.defaultSession.loadExtension(extPath, { : await targetSession.loadExtension(extPath, {
allowFileAccess: true, allowFileAccess: true,
}); });
deps.setYomitanExtension(extension); deps.setYomitanExtension(extension);
@@ -66,7 +103,7 @@ export async function loadYomitanExtension(
} catch (err) { } catch (err) {
logger.error('Failed to load Yomitan extension:', (err as Error).message); logger.error('Failed to load Yomitan extension:', (err as Error).message);
logger.error('Full error:', err); logger.error('Full error:', err);
deps.setYomitanExtension(null); clearRuntimeState();
return null; return null;
} }
} }

View File

@@ -4,6 +4,7 @@ import test from 'node:test';
import { import {
getYomitanExtensionSearchPaths, getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
resolveExistingYomitanExtensionPath, resolveExistingYomitanExtensionPath,
} from './yomitan-extension-paths'; } from './yomitan-extension-paths';
@@ -51,3 +52,19 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
assert.equal(resolved, null); 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);
});

View File

@@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath(
): string | null { ): string | null {
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync); 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;
}

View File

@@ -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']);
});

View File

@@ -0,0 +1,34 @@
type ParserWindowLike = {
isDestroyed?: () => boolean;
destroy?: () => void;
} | null;
export interface YomitanParserRuntimeStateDeps {
getYomitanParserWindow: () => ParserWindowLike;
setYomitanParserWindow: (window: null) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | 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);
}

View File

@@ -1,5 +1,5 @@
import electron from 'electron'; import electron from 'electron';
import type { BrowserWindow, Extension } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, session } = electron; const { BrowserWindow: ElectronBrowserWindow, session } = electron;
@@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions {
yomitanExt: Extension | null; yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null; getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void; setWindow: (window: BrowserWindow | null) => void;
yomitanSession?: Session | null;
onWindowClosed?: () => void; onWindowClosed?: () => void;
} }
@@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
session: session.defaultSession, session: options.yomitanSession ?? session.defaultSession,
}, },
}); });
options.setWindow(settingsWindow); options.setWindow(settingsWindow);

View File

@@ -23,6 +23,7 @@ import {
shell, shell,
protocol, protocol,
Extension, Extension,
Session,
Menu, Menu,
nativeImage, nativeImage,
Tray, Tray,
@@ -376,6 +377,8 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; 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 { import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer, shouldForceOverrideYomitanAnkiServer,
@@ -691,6 +694,7 @@ const firstRunSetupService = createFirstRunSetupService({
}); });
return dictionaries.length; return dictionaries.length;
}, },
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => { detectPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths( const installPaths = resolveDefaultMpvInstallPaths(
process.platform, process.platform,
@@ -1327,7 +1331,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
getConfig: () => { getConfig: () => {
const config = getResolvedConfig().anilist.characterDictionary; const config = getResolvedConfig().anilist.characterDictionary;
return { return {
enabled: config.enabled, enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
maxLoaded: config.maxLoaded, maxLoaded: config.maxLoaded,
profileScope: config.profileScope, profileScope: config.profileScope,
}; };
@@ -1347,6 +1351,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
importYomitanDictionary: async (zipPath) => { importYomitanDictionary: async (zipPath) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
);
return false;
}
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), { return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args), error: (message, ...args) => logger.error(message, ...args),
@@ -1354,6 +1364,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
deleteYomitanDictionary: async (dictionaryTitle) => { deleteYomitanDictionary: async (dictionaryTitle) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), { return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args), error: (message, ...args) => logger.error(message, ...args),
@@ -1361,6 +1377,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
return await upsertYomitanDictionarySettings( return await upsertYomitanDictionarySettings(
dictionaryTitle, dictionaryTitle,
@@ -1814,6 +1836,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
configReady: snapshot.configReady, configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount, dictionaryCount: snapshot.dictionaryCount,
canFinish: snapshot.canFinish, canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus, pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary, pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
windowsMpvShortcuts: snapshot.windowsMpvShortcuts, windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -1837,8 +1860,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return; return;
} }
if (submission.action === 'open-yomitan-settings') { if (submission.action === 'open-yomitan-settings') {
openYomitanSettings(); firstRunSetupMessage = openYomitanSettings()
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.'; ? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return; return;
} }
if (submission.action === 'refresh') { if (submission.action === 'refresh') {
@@ -2320,6 +2344,7 @@ const {
appState.yomitanParserWindow = null; appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null; appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null; appState.yomitanParserInitPromise = null;
appState.yomitanSession = null;
}, },
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
flushMpvLog: () => flushPendingMpvLogWrites(), flushMpvLog: () => flushPendingMpvLogWrites(),
@@ -2737,6 +2762,9 @@ const {
); );
}, },
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync(); characterDictionaryAutoSyncRuntime.scheduleSync();
}, },
updateCurrentMediaTitle: (title) => { updateCurrentMediaTitle: (title) => {
@@ -2780,6 +2808,7 @@ const {
tokenizer: { tokenizer: {
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => { setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null; appState.yomitanParserWindow = window as BrowserWindow | null;
@@ -2813,7 +2842,9 @@ const {
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
getResolvedConfig().subtitleStyle.enableJlpt, getResolvedConfig().subtitleStyle.enableJlpt,
), ),
getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled, getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () => getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption( getRuntimeBooleanOption(
@@ -2987,7 +3018,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
async function loadYomitanExtension(): Promise<Extension | null> { async function loadYomitanExtension(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.loadYomitanExtension(); const extension = await yomitanExtensionRuntime.loadYomitanExtension();
if (extension) { if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer(); await syncYomitanDefaultProfileAnkiServer();
} }
return extension; return extension;
@@ -2995,7 +3026,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> { async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
if (extension) { if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer(); await syncYomitanDefaultProfileAnkiServer();
} }
return extension; return extension;
@@ -3010,6 +3041,7 @@ function getPreferredYomitanAnkiServerUrl(): string {
function getYomitanParserRuntimeDeps() { function getYomitanParserRuntimeDeps() {
return { return {
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => { setYomitanParserWindow: (window: BrowserWindow | null) => {
appState.yomitanParserWindow = window; appState.yomitanParserWindow = window;
@@ -3026,6 +3058,10 @@ function getYomitanParserRuntimeDeps() {
} }
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> { async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
return;
}
const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
return; return;
@@ -3079,8 +3115,19 @@ function initializeOverlayRuntime(): void {
syncOverlayMpvSubtitleSuppression(); 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(); openYomitanSettingsHandler();
return true;
} }
const { const {
@@ -3496,8 +3543,13 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
openJellyfinSetupWindow: () => openJellyfinSetupWindow(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) => generateCharacterDictionary: async (targetPath?: string) => {
characterDictionaryRuntime.generateForCurrentMedia(targetPath), const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason();
if (disabledReason) {
throw new Error(disabledReason);
}
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
},
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
@@ -3520,10 +3572,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled: (enabled) =>
setOverlayDebugVisualizationEnabled(enabled), setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) => isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
tryHandleOverlayShortcutLocalFallback: (input) => getYomitanSession: () => appState.yomitanSession,
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === 'visible') { if (windowKind === 'visible') {
@@ -3584,9 +3637,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
}); });
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore, loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => { setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null; appState.yomitanParserWindow = window as BrowserWindow | null;
@@ -3600,6 +3659,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
setYomitanExtension: (extension) => { setYomitanExtension: (extension) => {
appState.yomitanExt = extension; appState.yomitanExt = extension;
}, },
setYomitanSession: (nextSession) => {
appState.yomitanSession = nextSession;
},
getYomitanExtension: () => appState.yomitanExt, getYomitanExtension: () => appState.yomitanExt,
getLoadInFlight: () => yomitanLoadInFlight, getLoadInFlight: () => yomitanLoadInFlight,
setLoadInFlight: (promise) => { setLoadInFlight: (promise) => {
@@ -3641,11 +3703,18 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
}); });
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({
yomitanExt,
getExistingWindow,
setWindow,
yomitanSession,
}) => {
openYomitanSettingsWindow({ openYomitanSettingsWindow({
yomitanExt: yomitanExt as Extension, yomitanExt: yomitanExt as Extension,
getExistingWindow: () => getExistingWindow() as BrowserWindow | null, getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
setWindow: (window) => setWindow(window as BrowserWindow | null), setWindow: (window) => setWindow(window as BrowserWindow | null),
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
onWindowClosed: () => { onWindowClosed: () => {
if (appState.yomitanParserWindow) { if (appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow); clearYomitanParserCachesForWindow(appState.yomitanParserWindow);

View File

@@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = []; const calls: string[] = [];
let currentWindow: unknown = null; let currentWindow: unknown = null;
const extension = { id: 'ext' }; const extension = { id: 'ext' };
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({ const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension, ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) => openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(`open:${(yomitanExt as { id: string }).id}`), calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
),
getExistingWindow: () => currentWindow, getExistingWindow: () => currentWindow,
setWindow: (window) => { setWindow: (window) => {
currentWindow = window; currentWindow = window;
calls.push('set-window'); calls.push('set-window');
}, },
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`), logError: (message) => calls.push(`error:${message}`),
})(); })();
@@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => {
yomitanExt: extension, yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(), getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window), setWindow: (window) => deps.setWindow(window),
yomitanSession: deps.getYomitanSession(),
}); });
deps.logWarn('warn'); deps.logWarn('warn');
deps.logError('error', new Error('boom')); 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' }); assert.deepEqual(currentWindow, { id: 'win' });
}); });

View File

@@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt; yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null; getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void; setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void; onWindowClosed?: () => void;
}) => void; }) => void;
getExistingWindow: () => TWindow | null; getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void; setWindow: (window: TWindow | null) => void;
getYomitanSession?: () => unknown | null;
logWarn: (message: string) => void; logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void;
}) { }) {
@@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt; yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null; getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void; setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void; onWindowClosed?: () => void;
}) => deps.openYomitanSettingsWindow(params), }) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(), getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window), setWindow: (window: TWindow | null) => deps.setWindow(window),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
logWarn: (message: string) => deps.logWarn(message), logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error), logError: (message: string, error: unknown) => deps.logError(message, error),
}); });

View File

@@ -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.',
);
});

View File

@@ -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.';
}

View File

@@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support
const completed = await service.markSetupCompleted(); const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed'); assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.completionSource, 'user'); assert.equal(completed.state.completionSource, 'user');
assert.equal(completed.state.yomitanSetupMode, 'internal');
});
});
test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.yomitanSetupMode, 'external');
assert.equal(completed.dictionaryCount, 0);
});
});
test('setup service does not probe internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => {
throw new Error('should not probe internal dictionaries in external mode');
},
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.externalYomitanConfigured, true);
assert.equal(snapshot.dictionaryCount, 0);
});
});
test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.state.yomitanSetupMode, null);
assert.equal(snapshot.canFinish, false);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
}); });
}); });

View File

@@ -26,6 +26,7 @@ export interface SetupStatusSnapshot {
configReady: boolean; configReady: boolean;
dictionaryCount: number; dictionaryCount: number;
canFinish: boolean; canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null; pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot; windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
@@ -139,10 +140,50 @@ function getEffectiveWindowsMpvShortcutPreferences(
}; };
} }
function isYomitanSetupSatisfied(options: {
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}): boolean {
if (!options.configReady) {
return false;
}
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
if (configReady && externalYomitanConfigured) {
return {
configReady,
dictionaryCount: 0,
externalYomitanConfigured,
};
}
return {
configReady,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
}
export function createFirstRunSetupService(deps: { export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
configDir: string; configDir: string;
getYomitanDictionaryCount: () => Promise<number>; getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>; detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>; installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () => detectWindowsMpvShortcuts?: () =>
@@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: {
}; };
const buildSnapshot = async (state: SetupState, message: string | null = null) => { const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount(); const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled(); const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.() ? await deps.detectWindowsMpvShortcuts?.()
@@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: {
state, state,
installedWindowsMpvShortcuts, installedWindowsMpvShortcuts,
); );
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return { return {
configReady, configReady,
dictionaryCount, dictionaryCount,
canFinish: dictionaryCount >= 1, canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled), pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary, pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: { windowsMpvShortcuts: {
@@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: {
return { return {
ensureSetupStateInitialized: async () => { ensureSetupStateInitialized: async () => {
const state = readState(); const state = readState();
if (isSetupCompleted(state)) { const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (
isSetupCompleted(state) &&
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
) {
completed = true; completed = true;
return refreshWithState(state); return refreshWithState(state);
} }
const dictionaryCount = await deps.getYomitanDictionaryCount(); if (yomitanSetupSatisfied) {
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
if (configReady && dictionaryCount >= 1) {
const completedState = writeState({ const completedState = writeState({
...state, ...state,
status: 'completed', status: 'completed',
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
completionSource: 'legacy_auto_detected', completionSource: 'legacy_auto_detected',
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: dictionaryCount, lastSeenYomitanDictionaryCount: dictionaryCount,
}); });
return buildSnapshot(completedState); return buildSnapshot(completedState);
@@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: {
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete', status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: dictionaryCount, lastSeenYomitanDictionaryCount: dictionaryCount,
}), }),
); );
@@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: {
status: 'completed', status: 'completed',
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
completionSource: 'user', completionSource: 'user',
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount, lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
}), }),
); );

View File

@@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
configReady: true, configReady: true,
dictionaryCount: 0, dictionaryCount: 0,
canFinish: false, canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional', pluginStatus: 'optional',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
windowsMpvShortcuts: { windowsMpvShortcuts: {
@@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
configReady: true, configReady: true,
dictionaryCount: 1, dictionaryCount: 1,
canFinish: true, canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed', pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv', pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: { windowsMpvShortcuts: {
@@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.match(html, /Reinstall mpv plugin/); assert.match(html, /Reinstall mpv plugin/);
}); });
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: true,
externalYomitanConfigured: true,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /External profile configured/);
assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh', action: 'refresh',
@@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
configReady: false, configReady: false,
dictionaryCount: 0, dictionaryCount: 0,
canFinish: false, canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional', pluginStatus: 'optional',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
windowsMpvShortcuts: { windowsMpvShortcuts: {

View File

@@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel {
configReady: boolean; configReady: boolean;
dictionaryCount: number; dictionaryCount: number;
canFinish: boolean; canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed'; pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null; pluginInstallPathSummary: string | null;
windowsMpvShortcuts: { windowsMpvShortcuts: {
@@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>` </div>`
: ''; : '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
? 'External'
: model.dictionaryCount >= 1
? 'Ready'
: 'Missing';
const yomitanBadgeTone = model.externalYomitanConfigured
? 'ready'
: model.dictionaryCount >= 1
? 'ready'
: 'warn';
const footerMessage = model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="card"> <div class="card">
<div> <div>
<strong>Yomitan dictionaries</strong> <strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div> <div class="meta">${escapeHtml(yomitanMeta)}</div>
</div> </div>
${renderStatusBadge( ${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div> </div>
${windowsShortcutCard} ${windowsShortcutCard}
<div class="actions"> <div class="actions">
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button> <button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div> </div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div> <div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div> <div class="footer">${escapeHtml(footerMessage)}</div>
</main> </main>
</body> </body>
</html>`; </html>`;

View File

@@ -8,6 +8,7 @@ import {
test('overlay window factory main deps builders return mapped handlers', () => { test('overlay window factory main deps builders return mapped handlers', () => {
const calls: string[] = []; const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({ const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }), createOverlayWindowCore: (kind) => ({ kind }),
isDev: true, isDev: true,
@@ -18,11 +19,13 @@ test('overlay window factory main deps builders return mapped handlers', () => {
tryHandleOverlayShortcutLocalFallback: () => false, tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'), forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`), onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
}); });
const overlayDeps = buildOverlayDeps(); const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true); assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.isOverlayVisible('visible'), true); assert.equal(overlayDeps.isOverlayVisible('visible'), true);
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
overlayDeps.forwardTabToMpv(); overlayDeps.forwardTabToMpv();
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: { export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: ( createOverlayWindowCore: (
kind: 'visible' | 'modal', kind: 'visible' | 'modal',
@@ -10,6 +12,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
yomitanSession?: Session | null;
}, },
) => TWindow; ) => TWindow;
isDev: boolean; isDev: boolean;
@@ -20,6 +23,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
getYomitanSession?: () => Session | null;
}) { }) {
return () => ({ return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore, createOverlayWindowCore: deps.createOverlayWindowCore,
@@ -31,6 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv, forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
}); });
} }

View File

@@ -9,12 +9,14 @@ import {
test('create overlay window handler forwards options and kind', () => { test('create overlay window handler forwards options and kind', () => {
const calls: string[] = []; const calls: string[] = [];
const window = { id: 1 }; const window = { id: 1 };
const yomitanSession = { id: 'session' } as never;
const createOverlayWindow = createCreateOverlayWindowHandler({ const createOverlayWindow = createCreateOverlayWindowHandler({
createOverlayWindowCore: (kind, options) => { createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`); calls.push(`kind:${kind}`);
assert.equal(options.isDev, true); assert.equal(options.isDev, true);
assert.equal(options.isOverlayVisible('visible'), true); assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('modal'), false); assert.equal(options.isOverlayVisible('modal'), false);
assert.equal(options.yomitanSession, yomitanSession);
options.forwardTabToMpv(); options.forwardTabToMpv();
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true); options.setOverlayDebugVisualizationEnabled(true);
@@ -29,6 +31,7 @@ test('create overlay window handler forwards options and kind', () => {
tryHandleOverlayShortcutLocalFallback: () => false, tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'), forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`), onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
}); });
assert.equal(createOverlayWindow('visible'), window); assert.equal(createOverlayWindow('visible'), window);

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
type OverlayWindowKind = 'visible' | 'modal'; type OverlayWindowKind = 'visible' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: { export function createCreateOverlayWindowHandler<TWindow>(deps: {
@@ -12,6 +14,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void; onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
}, },
) => TWindow; ) => TWindow;
isDev: boolean; isDev: boolean;
@@ -22,6 +25,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void; onWindowClosed: (windowKind: OverlayWindowKind) => void;
getYomitanSession?: () => Session | null;
}) { }) {
return (kind: OverlayWindowKind): TWindow => { return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, { return deps.createOverlayWindowCore(kind, {
@@ -33,6 +37,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv, forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
}); });
}; };
} }

View File

@@ -7,10 +7,14 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
let modalWindow: { kind: string } | null = null; let modalWindow: { kind: string } | null = null;
let debugEnabled = false; let debugEnabled = false;
const calls: string[] = []; const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const runtime = createOverlayWindowRuntimeHandlers({ const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }), createOverlayWindowCore: (kind, options) => {
assert.equal(options.yomitanSession, yomitanSession);
return { kind };
},
isDev: true, isDev: true,
ensureOverlayWindowLevel: () => calls.push('ensure-level'), ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
@@ -21,6 +25,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
tryHandleOverlayShortcutLocalFallback: () => false, tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'), forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`), onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
}, },
setMainWindow: (window) => { setMainWindow: (window) => {
mainWindow = window; mainWindow = window;

View File

@@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
return (): TokenizerDepsRuntimeOptions => ({ return (): TokenizerDepsRuntimeOptions => ({
getYomitanExt: () => deps.getYomitanExt(), getYomitanExt: () => deps.getYomitanExt(),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
getYomitanParserWindow: () => deps.getYomitanParserWindow(), getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),

View File

@@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => {
return null; return null;
}, },
userDataPath: '/tmp/subminer', userDataPath: '/tmp/subminer',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => null, getYomitanParserWindow: () => null,
setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserWindow: () => calls.push('set-window'),
setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserReadyPromise: () => calls.push('set-ready'),
setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanParserInitPromise: () => calls.push('set-init'),
setYomitanExtension: () => calls.push('set-ext'), setYomitanExtension: () => calls.push('set-ext'),
setYomitanSession: () => calls.push('set-session'),
})(); })();
assert.equal(deps.userDataPath, '/tmp/subminer'); assert.equal(deps.userDataPath, '/tmp/subminer');
assert.equal(deps.externalProfilePath, '/tmp/gsm-profile');
await deps.loadYomitanExtensionCore({} as never); await deps.loadYomitanExtensionCore({} as never);
deps.setYomitanParserWindow(null); deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null); deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null); deps.setYomitanParserInitPromise(null);
deps.setYomitanExtension(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 () => { test('ensure yomitan extension loaded main deps builder maps callbacks', async () => {

View File

@@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan
return (): LoadYomitanExtensionMainDeps => ({ return (): LoadYomitanExtensionMainDeps => ({
loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options), loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options),
userDataPath: deps.userDataPath, userDataPath: deps.userDataPath,
externalProfilePath: deps.externalProfilePath,
getYomitanParserWindow: () => deps.getYomitanParserWindow(), getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise), setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise),
setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise), setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise),
setYomitanExtension: (extension) => deps.setYomitanExtension(extension), setYomitanExtension: (extension) => deps.setYomitanExtension(extension),
setYomitanSession: (session) => deps.setYomitanSession(session),
}); });
} }

View File

@@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async
const loadYomitanExtension = createLoadYomitanExtensionHandler({ const loadYomitanExtension = createLoadYomitanExtensionHandler({
loadYomitanExtensionCore: async (options) => { loadYomitanExtensionCore: async (options) => {
calls.push(`path:${options.userDataPath}`); calls.push(`path:${options.userDataPath}`);
calls.push(`external:${options.externalProfilePath ?? ''}`);
assert.equal(options.getYomitanParserWindow(), parserWindow); assert.equal(options.getYomitanParserWindow(), parserWindow);
options.setYomitanParserWindow(null); options.setYomitanParserWindow(null);
options.setYomitanParserReadyPromise(null); options.setYomitanParserReadyPromise(null);
options.setYomitanParserInitPromise(null); options.setYomitanParserInitPromise(null);
options.setYomitanExtension(extension); options.setYomitanExtension(extension);
options.setYomitanSession(null);
return extension; return extension;
}, },
userDataPath: '/tmp/subminer', userDataPath: '/tmp/subminer',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => parserWindow, getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserWindow: () => calls.push('set-window'),
setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserReadyPromise: () => calls.push('set-ready'),
setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanParserInitPromise: () => calls.push('set-init'),
setYomitanExtension: () => calls.push('set-ext'), setYomitanExtension: () => calls.push('set-ext'),
setYomitanSession: () => calls.push('set-session'),
}); });
assert.equal(await loadYomitanExtension(), extension); 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 () => { test('ensure yomitan loader returns existing extension when available', async () => {

View File

@@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext
export function createLoadYomitanExtensionHandler(deps: { export function createLoadYomitanExtensionHandler(deps: {
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>; loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
userDataPath: YomitanExtensionLoaderDeps['userDataPath']; userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath'];
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession'];
}) { }) {
return async (): Promise<Extension | null> => { return async (): Promise<Extension | null> => {
return deps.loadYomitanExtensionCore({ return deps.loadYomitanExtensionCore({
userDataPath: deps.userDataPath, userDataPath: deps.userDataPath,
externalProfilePath: deps.externalProfilePath,
getYomitanParserWindow: deps.getYomitanParserWindow, getYomitanParserWindow: deps.getYomitanParserWindow,
setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow,
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
setYomitanExtension: deps.setYomitanExtension, setYomitanExtension: deps.setYomitanExtension,
setYomitanSession: deps.setYomitanSession,
}); });
}; };
} }

View File

@@ -9,6 +9,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
let parserWindow: unknown = null; let parserWindow: unknown = null;
let readyPromise: Promise<void> | null = null; let readyPromise: Promise<void> | null = null;
let initPromise: Promise<boolean> | null = null; let initPromise: Promise<boolean> | null = null;
let yomitanSession: unknown = null;
let receivedExternalProfilePath = '';
let loadCalls = 0; let loadCalls = 0;
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
releaseLoad: null, releaseLoad: null,
@@ -17,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
const runtime = createYomitanExtensionRuntime({ const runtime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async (options) => { loadYomitanExtensionCore: async (options) => {
loadCalls += 1; loadCalls += 1;
receivedExternalProfilePath = options.externalProfilePath ?? '';
options.setYomitanParserWindow(null); options.setYomitanParserWindow(null);
options.setYomitanParserReadyPromise(Promise.resolve()); options.setYomitanParserReadyPromise(Promise.resolve());
options.setYomitanParserInitPromise(Promise.resolve(true)); options.setYomitanParserInitPromise(Promise.resolve(true));
options.setYomitanSession({ id: 'session' } as never);
return await new Promise<Extension | null>((resolve) => { return await new Promise<Extension | null>((resolve) => {
releaseLoadState.releaseLoad = (value) => { releaseLoadState.releaseLoad = (value) => {
options.setYomitanExtension(value); options.setYomitanExtension(value);
@@ -28,6 +32,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
}); });
}, },
userDataPath: '/tmp', userDataPath: '/tmp',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => parserWindow as never, getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: (window) => { setYomitanParserWindow: (window) => {
parserWindow = window; parserWindow = window;
@@ -41,6 +46,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
setYomitanExtension: (next) => { setYomitanExtension: (next) => {
extension = next; extension = next;
}, },
setYomitanSession: (next) => {
yomitanSession = next;
},
getYomitanExtension: () => extension, getYomitanExtension: () => extension,
getLoadInFlight: () => inFlight, getLoadInFlight: () => inFlight,
setLoadInFlight: (promise) => { setLoadInFlight: (promise) => {
@@ -55,6 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
assert.equal(parserWindow, null); assert.equal(parserWindow, null);
assert.ok(readyPromise); assert.ok(readyPromise);
assert.ok(initPromise); assert.ok(initPromise);
assert.deepEqual(yomitanSession, { id: 'session' });
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
const fakeExtension = { id: 'yomitan' } as Extension; const fakeExtension = { id: 'yomitan' } as Extension;
const releaseLoad = releaseLoadState.releaseLoad; 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 () => { test('yomitan extension runtime direct load delegates to core', async () => {
let loadCalls = 0; let loadCalls = 0;
let receivedExternalProfilePath = '';
let yomitanSession: unknown = null;
const runtime = createYomitanExtensionRuntime({ const runtime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async () => { loadYomitanExtensionCore: async (options) => {
loadCalls += 1; loadCalls += 1;
receivedExternalProfilePath = options.externalProfilePath ?? '';
options.setYomitanSession({ id: 'session' } as never);
return null; return null;
}, },
userDataPath: '/tmp', userDataPath: '/tmp',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => null, getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {}, setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {}, setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {}, setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {}, setYomitanExtension: () => {},
setYomitanSession: (next) => {
yomitanSession = next;
},
getYomitanExtension: () => null, getYomitanExtension: () => null,
getLoadInFlight: () => null, getLoadInFlight: () => null,
setLoadInFlight: () => {}, setLoadInFlight: () => {},
@@ -93,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => {
assert.equal(await runtime.loadYomitanExtension(), null); assert.equal(await runtime.loadYomitanExtension(), null);
assert.equal(loadCalls, 1); assert.equal(loadCalls, 1);
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
assert.deepEqual(yomitanSession, { id: 'session' });
}); });

View File

@@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
loadYomitanExtensionCore: deps.loadYomitanExtensionCore, loadYomitanExtensionCore: deps.loadYomitanExtensionCore,
userDataPath: deps.userDataPath, userDataPath: deps.userDataPath,
externalProfilePath: deps.externalProfilePath,
getYomitanParserWindow: deps.getYomitanParserWindow, getYomitanParserWindow: deps.getYomitanParserWindow,
setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow,
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
setYomitanExtension: deps.setYomitanExtension, setYomitanExtension: deps.setYomitanExtension,
setYomitanSession: deps.setYomitanSession,
}); });
const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler( const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler(
buildLoadYomitanExtensionMainDepsHandler(), buildLoadYomitanExtensionMainDepsHandler(),

View File

@@ -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);
});

View File

@@ -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`,
);
},
};
}

View File

@@ -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(<redacted>)',
);
});
test('formatSkippedYomitanWriteAction falls back when value is blank', () => {
assert.equal(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '),
'upsertYomitanDictionarySettings(<redacted>)',
);
});

View File

@@ -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 '<redacted>';
}
if (actionName === 'importYomitanDictionary') {
const basename = path.basename(trimmed);
return basename || '<redacted>';
}
return '<redacted>';
}
export function formatSkippedYomitanWriteAction(
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
rawValue: string,
): string {
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
}

View File

@@ -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 () => { 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({ const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => { openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
opened = true; forwardedSession = nextSession as { id: string } | null;
}, },
getExistingWindow: () => null, getExistingWindow: () => null,
setWindow: () => {}, setWindow: () => {},
getYomitanSession: () => yomitanSession,
logWarn: () => {}, logWarn: () => {},
logError: () => {}, logError: () => {},
}); });
@@ -37,5 +39,5 @@ test('yomitan opener opens settings window when extension is available', async (
openSettings(); openSettings();
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
assert.equal(opened, true); assert.equal(forwardedSession, yomitanSession);
}); });

View File

@@ -1,5 +1,6 @@
type YomitanExtensionLike = unknown; type YomitanExtensionLike = unknown;
type BrowserWindowLike = unknown; type BrowserWindowLike = unknown;
type SessionLike = unknown;
export function createOpenYomitanSettingsHandler(deps: { export function createOpenYomitanSettingsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>; ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
@@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: {
yomitanExt: YomitanExtensionLike; yomitanExt: YomitanExtensionLike;
getExistingWindow: () => BrowserWindowLike | null; getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void; setWindow: (window: BrowserWindowLike | null) => void;
yomitanSession?: SessionLike | null;
onWindowClosed?: () => void; onWindowClosed?: () => void;
}) => void; }) => void;
getExistingWindow: () => BrowserWindowLike | null; getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void; setWindow: (window: BrowserWindowLike | null) => void;
getYomitanSession?: () => SessionLike | null;
logWarn: (message: string) => void; logWarn: (message: string) => void;
logError: (message: string, error: unknown) => 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.'); deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return; return;
} }
const yomitanSession = deps.getYomitanSession?.() ?? null;
if (!yomitanSession) {
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
return;
}
deps.openYomitanSettingsWindow({ deps.openYomitanSettingsWindow({
yomitanExt: extension, yomitanExt: extension,
getExistingWindow: deps.getExistingWindow, getExistingWindow: deps.getExistingWindow,
setWindow: deps.setWindow, setWindow: deps.setWindow,
yomitanSession,
}); });
})().catch((error) => { })().catch((error) => {
deps.logError('Failed to open Yomitan settings window.', error); deps.logError('Failed to open Yomitan settings window.', error);

View File

@@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime';
test('yomitan settings runtime composes opener with built deps', async () => { test('yomitan settings runtime composes opener with built deps', async () => {
let existingWindow: { id: string } | null = null; let existingWindow: { id: string } | null = null;
const calls: string[] = []; const calls: string[] = [];
const yomitanSession = { id: 'session' };
const runtime = createYomitanSettingsRuntime({ const runtime = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => { openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
calls.push('open-window'); calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
const current = getExistingWindow(); const current = getExistingWindow();
if (!current) { if (!current) {
setWindow({ id: 'settings' }); setWindow({ id: 'settings' });
@@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => {
setWindow: (window) => { setWindow: (window) => {
existingWindow = window as { id: string } | null; existingWindow = window as { id: string } | null;
}, },
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${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)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(existingWindow, { id: 'settings' }); 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.']);
}); });

View File

@@ -1,4 +1,4 @@
import type { BrowserWindow, Extension } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import type { import type {
Keybinding, Keybinding,
@@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState(
export interface AppState { export interface AppState {
yomitanExt: Extension | null; yomitanExt: Extension | null;
yomitanSession: Session | null;
yomitanSettingsWindow: BrowserWindow | null; yomitanSettingsWindow: BrowserWindow | null;
yomitanParserWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null;
@@ -219,6 +220,7 @@ export interface StartupState {
export function createAppState(values: AppStateInitialValues): AppState { export function createAppState(values: AppStateInitialValues): AppState {
return { return {
yomitanExt: null, yomitanExt: null,
yomitanSession: null,
yomitanSettingsWindow: null, yomitanSettingsWindow: null,
yomitanParserWindow: null, yomitanParserWindow: null,
anilistSetupWindow: null, anilistSetupWindow: null,

View File

@@ -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) => { withTempDir((root) => {
const configDir = path.join(root, 'SubMiner'); const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
@@ -74,10 +74,13 @@ test('ensureDefaultConfigBootstrap does not seed default config into an existing
ensureDefaultConfigBootstrap({ ensureDefaultConfigBootstrap({
configDir, configDir,
configFilePaths: getDefaultConfigFilePaths(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'); 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(); const state = createDefaultSetupState();
state.status = 'completed'; state.status = 'completed';
state.completionSource = 'user'; state.completionSource = 'user';
state.yomitanSetupMode = 'internal';
state.lastSeenYomitanDictionaryCount = 2; state.lastSeenYomitanDictionaryCount = 2;
writeSetupState(statePath, state); writeSetupState(statePath, state);
@@ -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) => { withTempDir((root) => {
const statePath = getSetupStatePath(root); const statePath = getSetupStatePath(root);
fs.writeFileSync( fs.writeFileSync(
@@ -115,10 +119,11 @@ test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
); );
assert.deepEqual(readSetupState(statePath), { assert.deepEqual(readSetupState(statePath), {
version: 2, version: 3,
status: 'incomplete', status: 'incomplete',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0, lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown', pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
@@ -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', () => { test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
const linuxHomeDir = path.join(path.sep, 'tmp', 'home'); const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg'); const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');

View File

@@ -5,6 +5,7 @@ import { resolveConfigDir } from '../config/path-resolution';
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled'; export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null; export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
export type SetupYomitanMode = 'internal' | 'external' | null;
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed'; export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
@@ -14,10 +15,11 @@ export interface SetupWindowsMpvShortcutPreferences {
} }
export interface SetupState { export interface SetupState {
version: 2; version: 3;
status: SetupStateStatus; status: SetupStateStatus;
completedAt: string | null; completedAt: string | null;
completionSource: SetupCompletionSource; completionSource: SetupCompletionSource;
yomitanSetupMode: SetupYomitanMode;
lastSeenYomitanDictionaryCount: number; lastSeenYomitanDictionaryCount: number;
pluginInstallStatus: SetupPluginInstallStatus; pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null; pluginInstallPathSummary: string | null;
@@ -52,10 +54,11 @@ function asObject(value: unknown): Record<string, unknown> | null {
export function createDefaultSetupState(): SetupState { export function createDefaultSetupState(): SetupState {
return { return {
version: 2, version: 3,
status: 'incomplete', status: 'incomplete',
completedAt: null, completedAt: null,
completionSource: null, completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0, lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown', pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null, pluginInstallPathSummary: null,
@@ -74,11 +77,12 @@ export function normalizeSetupState(value: unknown): SetupState | null {
const status = record.status; const status = record.status;
const pluginInstallStatus = record.pluginInstallStatus; const pluginInstallStatus = record.pluginInstallStatus;
const completionSource = record.completionSource; const completionSource = record.completionSource;
const yomitanSetupMode = record.yomitanSetupMode;
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences); const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus; const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
if ( if (
(version !== 1 && version !== 2) || (version !== 1 && version !== 2 && version !== 3) ||
(status !== 'incomplete' && (status !== 'incomplete' &&
status !== 'in_progress' && status !== 'in_progress' &&
status !== 'completed' && status !== 'completed' &&
@@ -94,16 +98,26 @@ export function normalizeSetupState(value: unknown): SetupState | null {
windowsMpvShortcutLastStatus !== 'failed') || windowsMpvShortcutLastStatus !== 'failed') ||
(completionSource !== null && (completionSource !== null &&
completionSource !== 'user' && completionSource !== 'user' &&
completionSource !== 'legacy_auto_detected') completionSource !== 'legacy_auto_detected') ||
(version === 3 &&
yomitanSetupMode !== null &&
yomitanSetupMode !== 'internal' &&
yomitanSetupMode !== 'external')
) { ) {
return null; return null;
} }
return { return {
version: 2, version: 3,
status, status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource, completionSource,
yomitanSetupMode:
version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external')
? yomitanSetupMode
: status === 'completed'
? 'internal'
: null,
lastSeenYomitanDictionaryCount: lastSeenYomitanDictionaryCount:
typeof record.lastSeenYomitanDictionaryCount === 'number' && typeof record.lastSeenYomitanDictionaryCount === 'number' &&
Number.isFinite(record.lastSeenYomitanDictionaryCount) && Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
@@ -208,13 +222,8 @@ export function ensureDefaultConfigBootstrap(options: {
const existsSync = options.existsSync ?? fs.existsSync; const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync; const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync; const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
const configDirExists = existsSync(options.configDir);
if ( if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
existsSync(options.configFilePaths.jsoncPath) ||
existsSync(options.configFilePaths.jsonPath) ||
configDirExists
) {
return; return;
} }

View File

@@ -501,6 +501,10 @@ export interface AnilistConfig {
characterDictionary?: AnilistCharacterDictionaryConfig; characterDictionary?: AnilistCharacterDictionaryConfig;
} }
export interface YomitanConfig {
externalProfilePath?: string;
}
export interface JellyfinConfig { export interface JellyfinConfig {
enabled?: boolean; enabled?: boolean;
serverUrl?: string; serverUrl?: string;
@@ -585,6 +589,7 @@ export interface Config {
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig; jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig; discordPresence?: DiscordPresenceConfig;
ai?: AiConfig; ai?: AiConfig;
@@ -725,6 +730,9 @@ export interface ResolvedConfig {
collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>; collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>;
}; };
}; };
yomitan: {
externalProfilePath: string;
};
jellyfin: { jellyfin: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;