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