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