From 8ae92ded33058c94a116e4f428a96df15e94b3b6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Mar 2026 02:08:02 -0700 Subject: [PATCH] Add read-only external Yomitan profile support - add `yomitan.externalProfilePath` config and default/template wiring - load Yomitan from an external Electron profile/session when configured - disable SubMiner Yomitan writes/settings UI in external-profile mode and update docs/tests --- changes/external-yomitan-profile.md | 5 ++ config.example.jsonc | 11 ++++ docs-site/character-dictionary.md | 4 ++ docs-site/configuration.md | 26 ++++++++ docs-site/public/config.example.jsonc | 11 ++++ docs-site/troubleshooting.md | 1 + src/config/config.test.ts | 1 + src/config/definitions.ts | 3 +- .../definitions/defaults-integrations.ts | 12 +++- .../definitions/domain-registry.test.ts | 2 + .../definitions/options-integrations.ts | 7 ++ src/config/definitions/template-sections.ts | 9 +++ src/config/resolve/integrations.ts | 16 +++++ src/config/resolve/jellyfin.test.ts | 23 +++++++ src/core/services/tokenizer.ts | 5 +- .../tokenizer/yomitan-parser-runtime.ts | 9 ++- src/core/services/yomitan-extension-loader.ts | 64 +++++++++++++------ .../services/yomitan-extension-paths.test.ts | 17 +++++ src/core/services/yomitan-extension-paths.ts | 13 ++++ src/core/services/yomitan-settings.ts | 5 +- src/main.ts | 48 +++++++++++++- .../subtitle-tokenization-main-deps.ts | 1 + ...yomitan-extension-loader-main-deps.test.ts | 13 +++- .../yomitan-extension-loader-main-deps.ts | 2 + .../runtime/yomitan-extension-loader.test.ts | 14 +++- src/main/runtime/yomitan-extension-loader.ts | 4 ++ .../runtime/yomitan-extension-runtime.test.ts | 8 +++ src/main/runtime/yomitan-extension-runtime.ts | 2 + src/main/state.ts | 4 +- src/types.ts | 8 +++ 30 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 changes/external-yomitan-profile.md diff --git a/changes/external-yomitan-profile.md b/changes/external-yomitan-profile.md new file mode 100644 index 00000000..c39d1db3 --- /dev/null +++ b/changes/external-yomitan-profile.md @@ -0,0 +1,5 @@ +type: added +area: yomitan + +- Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode. +- SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile. diff --git a/config.example.jsonc b/config.example.jsonc index a946f283..23a57d34 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -385,6 +385,17 @@ } // Character dictionary setting. }, // Anilist API credentials and update behavior. + // ========================================== + // Yomitan + // Optional external Yomitan profile integration. + // Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode. + // For GameSentenceMiner, the default Linux overlay profile is usually ~/.config/gsm_overlay. + // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. + // ========================================== + "yomitan": { + "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + }, // Optional external Yomitan profile integration. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index e46f88b8..51dfdf36 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -62,6 +62,10 @@ Character dictionary sync is disabled by default. To turn it on: The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot. ::: +::: warning +If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but character-dictionary auto-sync does not import or update the merged dictionary. +::: + ## Name Generation A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for: diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2f6550c6..287ed0f9 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -995,6 +995,32 @@ Current post-watch behavior: - If embedded AniList auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app. - Failed updates are retried with a persistent backoff queue in the background. +### 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 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 auto-sync is effectively disabled in this mode because it requires Yomitan writes. + Setup flow details: 1. Set `anilist.enabled` to `true`. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index a946f283..23a57d34 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -385,6 +385,17 @@ } // Character dictionary setting. }, // Anilist API credentials and update behavior. + // ========================================== + // Yomitan + // Optional external Yomitan profile integration. + // Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode. + // For GameSentenceMiner, the default Linux overlay profile is usually ~/.config/gsm_overlay. + // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. + // ========================================== + "yomitan": { + "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + }, // Optional external Yomitan profile integration. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 4705de89..1b49b8a5 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -182,6 +182,7 @@ If you installed from the AppImage and see this error, the package may be incomp - Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension". - Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported. +- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window. - If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below. ## MeCab / Tokenization diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 1e609d30..c8e62f8d 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false); assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false); + assert.equal(config.yomitan.externalProfilePath, ''); assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, false); diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 12e1b468..d8a8e55e 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -32,7 +32,7 @@ const { startupWarmups, auto_start_overlay, } = CORE_DEFAULT_CONFIG; -const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } = +const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; @@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { auto_start_overlay, jimaku, anilist, + yomitan, jellyfin, discordPresence, ai, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 0df432b9..e1c9f73b 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, - 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen' + | 'ankiConnect' + | 'jimaku' + | 'anilist' + | 'yomitan' + | 'jellyfin' + | 'discordPresence' + | 'ai' + | 'youtubeSubgen' > = { ankiConnect: { enabled: false, @@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, }, }, + yomitan: { + externalProfilePath: '', + }, jellyfin: { enabled: false, serverUrl: '', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index a7859e35..fdce816d 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', () 'ankiConnect.enabled', 'anilist.characterDictionary.enabled', 'anilist.characterDictionary.collapsibleSections.description', + 'yomitan.externalProfilePath', 'immersionTracking.enabled', ]) { assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`); @@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () => 'startupWarmups', 'subtitleStyle', 'ankiConnect', + 'yomitan', 'immersionTracking', ]; diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 7e786eeb..4ba11f63 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -211,6 +211,13 @@ export function buildIntegrationConfigOptionRegistry( description: 'Open the Voiced by section by default in character dictionary glossary entries.', }, + { + path: 'yomitan.externalProfilePath', + kind: 'string', + defaultValue: defaultConfig.yomitan.externalProfilePath, + description: + 'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings.', + }, { path: 'jellyfin.enabled', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 93554c5e..91791dee 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -137,6 +137,15 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: 'anilist', }, + { + title: 'Yomitan', + description: [ + 'Optional external Yomitan profile integration.', + 'Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.', + 'In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.', + ], + key: 'yomitan', + }, { title: 'Jellyfin', description: [ diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 634869e4..a3872a5f 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -199,6 +199,22 @@ export function applyIntegrationConfig(context: ResolveContext): void { } } + if (isObject(src.yomitan)) { + const externalProfilePath = asString(src.yomitan.externalProfilePath); + if (externalProfilePath !== undefined) { + resolved.yomitan.externalProfilePath = externalProfilePath.trim(); + } else if (src.yomitan.externalProfilePath !== undefined) { + warn( + 'yomitan.externalProfilePath', + src.yomitan.externalProfilePath, + resolved.yomitan.externalProfilePath, + 'Expected string.', + ); + } + } else if (src.yomitan !== undefined) { + warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.'); + } + if (isObject(src.jellyfin)) { const enabled = asBoolean(src.jellyfin.enabled); if (enabled !== undefined) { diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index 5f43aed7..0cb81064 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -104,3 +104,26 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'), ); }); + +test('yomitan externalProfilePath is trimmed and invalid values warn', () => { + const { context, warnings } = createResolveContext({ + yomitan: { + externalProfilePath: ' /tmp/gsm-profile ', + }, + }); + + applyIntegrationConfig(context); + + assert.equal(context.resolved.yomitan.externalProfilePath, '/tmp/gsm-profile'); + + const invalid = createResolveContext({ + yomitan: { + externalProfilePath: 42 as never, + }, + }); + + applyIntegrationConfig(invalid.context); + + assert.equal(invalid.context.resolved.yomitan.externalProfilePath, ''); + assert.ok(invalid.warnings.some((warning) => warning.path === 'yomitan.externalProfilePath')); +}); diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index c06c248e..dbeaf321 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import { mergeTokens } from '../../token-merger'; import { createLogger } from '../../logger'; import { @@ -33,6 +33,7 @@ type MecabTokenEnrichmentFn = ( export interface TokenizerServiceDeps { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -63,6 +64,7 @@ interface MecabTokenizerLike { export interface TokenizerDepsRuntimeOptions { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -182,6 +184,7 @@ export function createTokenizerDepsRuntime( return { getYomitanExt: options.getYomitanExt, + getYomitanSession: options.getYomitanSession, getYomitanParserWindow: options.getYomitanParserWindow, setYomitanParserWindow: options.setYomitanParserWindow, getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index d9bd9bb0..fddda4e3 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { selectYomitanParseTokens } from './parser-selection-stage'; @@ -10,6 +10,7 @@ interface LoggerLike { interface YomitanParserRuntimeDeps { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -465,6 +466,7 @@ async function ensureYomitanParserWindow( const initPromise = (async () => { const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const parserWindow = new BrowserWindow({ show: false, width: 800, @@ -472,7 +474,7 @@ async function ensureYomitanParserWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); deps.setYomitanParserWindow(parserWindow); @@ -539,6 +541,7 @@ async function createYomitanExtensionWindow( } const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const window = new BrowserWindow({ show: false, width: 1200, @@ -546,7 +549,7 @@ async function createYomitanExtensionWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 407c14e4..6679126e 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -1,10 +1,12 @@ import electron from 'electron'; -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import * as fs from 'fs'; +import * as path from 'path'; import { createLogger } from '../../logger'; import { ensureExtensionCopy } from './yomitan-extension-copy'; import { getYomitanExtensionSearchPaths, + resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; @@ -14,35 +16,57 @@ const logger = createLogger('main:yomitan-extension-loader'); export interface YomitanExtensionLoaderDeps { userDataPath: string; extensionPath?: string; + externalProfilePath?: string; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; setYomitanParserReadyPromise: (promise: Promise | null) => void; setYomitanParserInitPromise: (promise: Promise | null) => void; setYomitanExtension: (extension: Extension | null) => void; + setYomitanSession: (session: Session | null) => void; } export async function loadYomitanExtension( deps: YomitanExtensionLoaderDeps, ): Promise { - const searchPaths = getYomitanExtensionSearchPaths({ - explicitPath: deps.extensionPath, - moduleDir: __dirname, - resourcesPath: process.resourcesPath, - userDataPath: deps.userDataPath, - }); - let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync); + const externalProfilePath = deps.externalProfilePath?.trim() ?? ''; + let extPath: string | null = null; + let targetSession: Session = session.defaultSession; - if (!extPath) { - logger.error('Yomitan extension not found in any search path'); - logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths); - return null; - } + if (externalProfilePath) { + const resolvedProfilePath = path.resolve(externalProfilePath); + extPath = resolveExternalYomitanExtensionPath(resolvedProfilePath, fs.existsSync); + if (!extPath) { + logger.error('External Yomitan extension not found in configured profile path'); + logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions')); + deps.setYomitanExtension(null); + deps.setYomitanSession(null); + return null; + } - const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath); - if (extensionCopy.copied) { - logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`); + 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); + deps.setYomitanExtension(null); + deps.setYomitanSession(null); + return null; + } + + const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath); + if (extensionCopy.copied) { + logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`); + } + extPath = extensionCopy.targetDir; } - extPath = extensionCopy.targetDir; const parserWindow = deps.getYomitanParserWindow(); if (parserWindow && !parserWindow.isDestroyed()) { @@ -51,14 +75,15 @@ export async function loadYomitanExtension( deps.setYomitanParserWindow(null); deps.setYomitanParserReadyPromise(null); deps.setYomitanParserInitPromise(null); + deps.setYomitanSession(targetSession); try { - const extensions = session.defaultSession.extensions; + const extensions = targetSession.extensions; const extension = extensions ? await extensions.loadExtension(extPath, { allowFileAccess: true, }) - : await session.defaultSession.loadExtension(extPath, { + : await targetSession.loadExtension(extPath, { allowFileAccess: true, }); deps.setYomitanExtension(extension); @@ -67,6 +92,7 @@ export async function loadYomitanExtension( logger.error('Failed to load Yomitan extension:', (err as Error).message); logger.error('Full error:', err); deps.setYomitanExtension(null); + deps.setYomitanSession(null); return null; } } diff --git a/src/core/services/yomitan-extension-paths.test.ts b/src/core/services/yomitan-extension-paths.test.ts index a1058615..95d6019e 100644 --- a/src/core/services/yomitan-extension-paths.test.ts +++ b/src/core/services/yomitan-extension-paths.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { getYomitanExtensionSearchPaths, + resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; @@ -51,3 +52,19 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani assert.equal(resolved, null); }); + +test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) => + candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'), + ); + + assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan')); +}); + +test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false); + + assert.equal(resolved, null); +}); diff --git a/src/core/services/yomitan-extension-paths.ts b/src/core/services/yomitan-extension-paths.ts index fd65379d..5256b450 100644 --- a/src/core/services/yomitan-extension-paths.ts +++ b/src/core/services/yomitan-extension-paths.ts @@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath( ): string | null { return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync); } + +export function resolveExternalYomitanExtensionPath( + externalProfilePath: string, + existsSync: (path: string) => boolean = fs.existsSync, +): string | null { + const normalizedProfilePath = externalProfilePath.trim(); + if (!normalizedProfilePath) { + return null; + } + + const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan'); + return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null; +} diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index d444b1b0..b03edcc5 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -1,5 +1,5 @@ import electron from 'electron'; -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import { createLogger } from '../../logger'; const { BrowserWindow: ElectronBrowserWindow, session } = electron; @@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions { yomitanExt: Extension | null; getExistingWindow: () => BrowserWindow | null; setWindow: (window: BrowserWindow | null) => void; + yomitanSession?: Session | null; onWindowClosed?: () => void; } @@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: options.yomitanSession ?? session.defaultSession, }, }); options.setWindow(settingsWindow); diff --git a/src/main.ts b/src/main.ts index 56157c96..70e257d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1347,6 +1347,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, importYomitanDictionary: async (zipPath) => { + if (isYomitanExternalReadOnlyMode()) { + logSkippedYomitanWrite(`importYomitanDictionary(${zipPath})`); + return false; + } await ensureYomitanExtensionLoaded(); return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), @@ -1354,6 +1358,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, deleteYomitanDictionary: async (dictionaryTitle) => { + if (isYomitanExternalReadOnlyMode()) { + logSkippedYomitanWrite(`deleteYomitanDictionary(${dictionaryTitle})`); + return false; + } await ensureYomitanExtensionLoaded(); return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), @@ -1361,6 +1369,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { + if (isYomitanExternalReadOnlyMode()) { + logSkippedYomitanWrite(`upsertYomitanDictionarySettings(${dictionaryTitle})`); + return false; + } await ensureYomitanExtensionLoaded(); return await upsertYomitanDictionarySettings( dictionaryTitle, @@ -2320,6 +2332,7 @@ const { appState.yomitanParserWindow = null; appState.yomitanParserReadyPromise = null; appState.yomitanParserInitPromise = null; + appState.yomitanSession = null; }, getWindowTracker: () => appState.windowTracker, flushMpvLog: () => flushPendingMpvLogWrites(), @@ -2780,6 +2793,7 @@ const { tokenizer: { buildTokenizerDepsMainDeps: { getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; @@ -2987,7 +3001,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); - if (extension) { + if (extension && !isYomitanExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -2995,7 +3009,7 @@ async function loadYomitanExtension(): Promise { async function ensureYomitanExtensionLoaded(): Promise { const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - if (extension) { + if (extension && !isYomitanExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -3007,9 +3021,24 @@ function getPreferredYomitanAnkiServerUrl(): string { return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); } +function getConfiguredExternalYomitanProfilePath(): string { + return getResolvedConfig().yomitan.externalProfilePath.trim(); +} + +function isYomitanExternalReadOnlyMode(): boolean { + return getConfiguredExternalYomitanProfilePath().length > 0; +} + +function logSkippedYomitanWrite(action: string): void { + logger.info( + `[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`, + ); +} + function getYomitanParserRuntimeDeps() { return { getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window: BrowserWindow | null) => { appState.yomitanParserWindow = window; @@ -3026,6 +3055,10 @@ function getYomitanParserRuntimeDeps() { } async function syncYomitanDefaultProfileAnkiServer(): Promise { + if (isYomitanExternalReadOnlyMode()) { + return; + } + const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { return; @@ -3080,6 +3113,12 @@ function initializeOverlayRuntime(): void { } function openYomitanSettings(): void { + if (isYomitanExternalReadOnlyMode()) { + logger.warn( + 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', + ); + return; + } openYomitanSettingsHandler(); } @@ -3587,6 +3626,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = const yomitanExtensionRuntime = createYomitanExtensionRuntime({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, + externalProfilePath: getConfiguredExternalYomitanProfilePath(), getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; @@ -3600,6 +3640,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({ setYomitanExtension: (extension) => { appState.yomitanExt = extension; }, + setYomitanSession: (nextSession) => { + appState.yomitanSession = nextSession; + }, getYomitanExtension: () => appState.yomitanExt, getLoadInFlight: () => yomitanLoadInFlight, setLoadInFlight: (promise) => { @@ -3646,6 +3689,7 @@ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSetting yomitanExt: yomitanExt as Extension, getExistingWindow: () => getExistingWindow() as BrowserWindow | null, setWindow: (window) => setWindow(window as BrowserWindow | null), + yomitanSession: appState.yomitanSession, onWindowClosed: () => { if (appState.yomitanParserWindow) { clearYomitanParserCachesForWindow(appState.yomitanParserWindow); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index 607ea401..1e27d192 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { return (): TokenizerDepsRuntimeOptions => ({ getYomitanExt: () => deps.getYomitanExt(), + getYomitanSession: () => deps.getYomitanSession?.() ?? null, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts index da97222d..d30440de 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts @@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => { return null; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => null, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), })(); assert.equal(deps.userDataPath, '/tmp/subminer'); + assert.equal(deps.externalProfilePath, '/tmp/gsm-profile'); await deps.loadYomitanExtensionCore({} as never); deps.setYomitanParserWindow(null); deps.setYomitanParserReadyPromise(null); deps.setYomitanParserInitPromise(null); deps.setYomitanExtension(null); - assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']); + deps.setYomitanSession(null as never); + assert.deepEqual(calls, [ + 'load-core', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan extension loaded main deps builder maps callbacks', async () => { diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.ts b/src/main/runtime/yomitan-extension-loader-main-deps.ts index 7acb73f8..0980b154 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.ts @@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan return (): LoadYomitanExtensionMainDeps => ({ loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options), userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise), setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise), setYomitanExtension: (extension) => deps.setYomitanExtension(extension), + setYomitanSession: (session) => deps.setYomitanSession(session), }); } diff --git a/src/main/runtime/yomitan-extension-loader.test.ts b/src/main/runtime/yomitan-extension-loader.test.ts index 5de56fa7..87fa0535 100644 --- a/src/main/runtime/yomitan-extension-loader.test.ts +++ b/src/main/runtime/yomitan-extension-loader.test.ts @@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async const loadYomitanExtension = createLoadYomitanExtensionHandler({ loadYomitanExtensionCore: async (options) => { calls.push(`path:${options.userDataPath}`); + calls.push(`external:${options.externalProfilePath ?? ''}`); assert.equal(options.getYomitanParserWindow(), parserWindow); options.setYomitanParserWindow(null); options.setYomitanParserReadyPromise(null); options.setYomitanParserInitPromise(null); options.setYomitanExtension(extension); + options.setYomitanSession(null); return extension; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => parserWindow, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), }); assert.equal(await loadYomitanExtension(), extension); - assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']); + assert.deepEqual(calls, [ + 'path:/tmp/subminer', + 'external:/tmp/gsm-profile', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan loader returns existing extension when available', async () => { diff --git a/src/main/runtime/yomitan-extension-loader.ts b/src/main/runtime/yomitan-extension-loader.ts index e668e8c9..23a7b609 100644 --- a/src/main/runtime/yomitan-extension-loader.ts +++ b/src/main/runtime/yomitan-extension-loader.ts @@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext export function createLoadYomitanExtensionHandler(deps: { loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; userDataPath: YomitanExtensionLoaderDeps['userDataPath']; + externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath']; getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; + setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession']; }) { return async (): Promise => { return deps.loadYomitanExtensionCore({ userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); }; } diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index bc0d26e4..1e37d982 100644 --- a/src/main/runtime/yomitan-extension-runtime.test.ts +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -9,6 +9,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after let parserWindow: unknown = null; let readyPromise: Promise | null = null; let initPromise: Promise | null = null; + let yomitanSession: unknown = null; let loadCalls = 0; const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { releaseLoad: null, @@ -28,6 +29,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after }); }, userDataPath: '/tmp', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => parserWindow as never, setYomitanParserWindow: (window) => { parserWindow = window; @@ -41,6 +43,9 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after setYomitanExtension: (next) => { extension = next; }, + setYomitanSession: (next) => { + yomitanSession = next; + }, getYomitanExtension: () => extension, getLoadInFlight: () => inFlight, setLoadInFlight: (promise) => { @@ -55,6 +60,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after assert.equal(parserWindow, null); assert.ok(readyPromise); assert.ok(initPromise); + assert.equal(yomitanSession, null); const fakeExtension = { id: 'yomitan' } as Extension; const releaseLoad = releaseLoadState.releaseLoad; @@ -81,11 +87,13 @@ test('yomitan extension runtime direct load delegates to core', async () => { return null; }, userDataPath: '/tmp', + externalProfilePath: '', getYomitanParserWindow: () => null, setYomitanParserWindow: () => {}, setYomitanParserReadyPromise: () => {}, setYomitanParserInitPromise: () => {}, setYomitanExtension: () => {}, + setYomitanSession: () => {}, getYomitanExtension: () => null, getLoadInFlight: () => null, setLoadInFlight: () => {}, diff --git a/src/main/runtime/yomitan-extension-runtime.ts b/src/main/runtime/yomitan-extension-runtime.ts index 7ecf9ade..60093a68 100644 --- a/src/main/runtime/yomitan-extension-runtime.ts +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ loadYomitanExtensionCore: deps.loadYomitanExtensionCore, userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler( buildLoadYomitanExtensionMainDepsHandler(), diff --git a/src/main/state.ts b/src/main/state.ts index ca2679e7..6dd67a77 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import type { Keybinding, @@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState( export interface AppState { yomitanExt: Extension | null; + yomitanSession: Session | null; yomitanSettingsWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null; @@ -219,6 +220,7 @@ export interface StartupState { export function createAppState(values: AppStateInitialValues): AppState { return { yomitanExt: null, + yomitanSession: null, yomitanSettingsWindow: null, yomitanParserWindow: null, anilistSetupWindow: null, diff --git a/src/types.ts b/src/types.ts index 36cd60b3..f1aa7647 100644 --- a/src/types.ts +++ b/src/types.ts @@ -501,6 +501,10 @@ export interface AnilistConfig { characterDictionary?: AnilistCharacterDictionaryConfig; } +export interface YomitanConfig { + externalProfilePath?: string; +} + export interface JellyfinConfig { enabled?: boolean; serverUrl?: string; @@ -585,6 +589,7 @@ export interface Config { auto_start_overlay?: boolean; jimaku?: JimakuConfig; anilist?: AnilistConfig; + yomitan?: YomitanConfig; jellyfin?: JellyfinConfig; discordPresence?: DiscordPresenceConfig; ai?: AiConfig; @@ -725,6 +730,9 @@ export interface ResolvedConfig { collapsibleSections: Required; }; }; + yomitan: { + externalProfilePath: string; + }; jellyfin: { enabled: boolean; serverUrl: string;