diff --git a/changes/external-yomitan-profile.md b/changes/external-yomitan-profile.md new file mode 100644 index 0000000..c39d1db --- /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 29b79f4..453da53 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -336,6 +336,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 e46f88b..51dfdf3 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 409e4fa..c5f57f1 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -912,6 +912,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 29b79f4..453da53 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -336,6 +336,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 4705de8..1b49b8a 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -182,6 +182,7 @@ If you installed from the AppImage and see this error, the package may be incomp - Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension". - Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported. +- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window. - If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below. ## MeCab / Tokenization diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 9b42abe..d1d37c8 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 9cefaba..1fcedf7 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -31,7 +31,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; @@ -52,6 +52,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { auto_start_overlay, jimaku, anilist, + yomitan, jellyfin, discordPresence, ai, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 0df432b..e1c9f73 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, - 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen' + | 'ankiConnect' + | 'jimaku' + | 'anilist' + | 'yomitan' + | 'jellyfin' + | 'discordPresence' + | 'ai' + | 'youtubeSubgen' > = { ankiConnect: { enabled: false, @@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, }, }, + yomitan: { + externalProfilePath: '', + }, jellyfin: { enabled: false, serverUrl: '', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index eedfbe7..ae6e9a0 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -25,6 +25,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}`); @@ -41,6 +42,7 @@ test('config template sections include expected domains and unique keys', () => 'startupWarmups', 'subtitleStyle', 'ankiConnect', + 'yomitan', 'immersionTracking', ]; diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 7e786ee..4ba11f6 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 9c0dfd9..c1a2a63 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -127,6 +127,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 634869e..a3872a5 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 5f43aed..0cb8106 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 c06c248..dbeaf32 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 d9bd9bb..fddda4e 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { selectYomitanParseTokens } from './parser-selection-stage'; @@ -10,6 +10,7 @@ interface LoggerLike { interface YomitanParserRuntimeDeps { getYomitanExt: () => Extension | null; + getYomitanSession?: () => Session | null; getYomitanParserWindow: () => BrowserWindow | null; setYomitanParserWindow: (window: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => Promise | null; @@ -465,6 +466,7 @@ async function ensureYomitanParserWindow( const initPromise = (async () => { const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const parserWindow = new BrowserWindow({ show: false, width: 800, @@ -472,7 +474,7 @@ async function ensureYomitanParserWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); deps.setYomitanParserWindow(parserWindow); @@ -539,6 +541,7 @@ async function createYomitanExtensionWindow( } const { BrowserWindow, session } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; const window = new BrowserWindow({ show: false, width: 1200, @@ -546,7 +549,7 @@ async function createYomitanExtensionWindow( webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: yomitanSession, }, }); diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 407c14e..6679126 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 a105861..95d6019 100644 --- a/src/core/services/yomitan-extension-paths.test.ts +++ b/src/core/services/yomitan-extension-paths.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { getYomitanExtensionSearchPaths, + resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; @@ -51,3 +52,19 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani assert.equal(resolved, null); }); + +test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) => + candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'), + ); + + assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan')); +}); + +test('resolveExternalYomitanExtensionPath returns null when external profile has no extension', () => { + const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile'); + const resolved = resolveExternalYomitanExtensionPath(profilePath, () => false); + + assert.equal(resolved, null); +}); diff --git a/src/core/services/yomitan-extension-paths.ts b/src/core/services/yomitan-extension-paths.ts index fd65379..5256b45 100644 --- a/src/core/services/yomitan-extension-paths.ts +++ b/src/core/services/yomitan-extension-paths.ts @@ -58,3 +58,16 @@ export function resolveYomitanExtensionPath( ): string | null { return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync); } + +export function resolveExternalYomitanExtensionPath( + externalProfilePath: string, + existsSync: (path: string) => boolean = fs.existsSync, +): string | null { + const normalizedProfilePath = externalProfilePath.trim(); + if (!normalizedProfilePath) { + return null; + } + + const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan'); + return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null; +} diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index d444b1b..b03edcc 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -1,5 +1,5 @@ import electron from 'electron'; -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import { createLogger } from '../../logger'; const { BrowserWindow: ElectronBrowserWindow, session } = electron; @@ -9,6 +9,7 @@ export interface OpenYomitanSettingsWindowOptions { yomitanExt: Extension | null; getExistingWindow: () => BrowserWindow | null; setWindow: (window: BrowserWindow | null) => void; + yomitanSession?: Session | null; onWindowClosed?: () => void; } @@ -37,7 +38,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti webPreferences: { contextIsolation: true, nodeIntegration: false, - session: session.defaultSession, + session: options.yomitanSession ?? session.defaultSession, }, }); options.setWindow(settingsWindow); diff --git a/src/main.ts b/src/main.ts index ce678fb..96f1f9d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1346,6 +1346,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), @@ -1353,6 +1357,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), @@ -1360,6 +1368,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { + if (isYomitanExternalReadOnlyMode()) { + logSkippedYomitanWrite(`upsertYomitanDictionarySettings(${dictionaryTitle})`); + return false; + } await ensureYomitanExtensionLoaded(); return await upsertYomitanDictionarySettings( dictionaryTitle, @@ -2319,6 +2331,7 @@ const { appState.yomitanParserWindow = null; appState.yomitanParserReadyPromise = null; appState.yomitanParserInitPromise = null; + appState.yomitanSession = null; }, getWindowTracker: () => appState.windowTracker, flushMpvLog: () => flushPendingMpvLogWrites(), @@ -2779,6 +2792,7 @@ const { tokenizer: { buildTokenizerDepsMainDeps: { getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; @@ -2986,7 +3000,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); - if (extension) { + if (extension && !isYomitanExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -2994,7 +3008,7 @@ async function loadYomitanExtension(): Promise { async function ensureYomitanExtensionLoaded(): Promise { const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - if (extension) { + if (extension && !isYomitanExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -3006,9 +3020,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; @@ -3025,6 +3054,10 @@ function getYomitanParserRuntimeDeps() { } async function syncYomitanDefaultProfileAnkiServer(): Promise { + if (isYomitanExternalReadOnlyMode()) { + return; + } + const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { return; @@ -3079,6 +3112,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(); } @@ -3577,6 +3616,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; @@ -3590,6 +3630,9 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({ setYomitanExtension: (extension) => { appState.yomitanExt = extension; }, + setYomitanSession: (nextSession) => { + appState.yomitanSession = nextSession; + }, getYomitanExtension: () => appState.yomitanExt, getLoadInFlight: () => yomitanLoadInFlight, setLoadInFlight: (promise) => { @@ -3636,6 +3679,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 607ea40..1e27d19 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -23,6 +23,7 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { return (): TokenizerDepsRuntimeOptions => ({ getYomitanExt: () => deps.getYomitanExt(), + getYomitanSession: () => deps.getYomitanSession?.() ?? null, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts index da97222..d30440d 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts @@ -13,20 +13,31 @@ test('load yomitan extension main deps builder maps callbacks', async () => { return null; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => null, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), })(); assert.equal(deps.userDataPath, '/tmp/subminer'); + assert.equal(deps.externalProfilePath, '/tmp/gsm-profile'); await deps.loadYomitanExtensionCore({} as never); deps.setYomitanParserWindow(null); deps.setYomitanParserReadyPromise(null); deps.setYomitanParserInitPromise(null); deps.setYomitanExtension(null); - assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']); + deps.setYomitanSession(null as never); + assert.deepEqual(calls, [ + 'load-core', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan extension loaded main deps builder maps callbacks', async () => { diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.ts b/src/main/runtime/yomitan-extension-loader-main-deps.ts index 7acb73f..0980b15 100644 --- a/src/main/runtime/yomitan-extension-loader-main-deps.ts +++ b/src/main/runtime/yomitan-extension-loader-main-deps.ts @@ -12,11 +12,13 @@ export function createBuildLoadYomitanExtensionMainDepsHandler(deps: LoadYomitan return (): LoadYomitanExtensionMainDeps => ({ loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options), userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise), setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise), setYomitanExtension: (extension) => deps.setYomitanExtension(extension), + setYomitanSession: (session) => deps.setYomitanSession(session), }); } diff --git a/src/main/runtime/yomitan-extension-loader.test.ts b/src/main/runtime/yomitan-extension-loader.test.ts index 5de56fa..87fa053 100644 --- a/src/main/runtime/yomitan-extension-loader.test.ts +++ b/src/main/runtime/yomitan-extension-loader.test.ts @@ -12,23 +12,35 @@ test('load yomitan extension handler forwards parser state dependencies', async const loadYomitanExtension = createLoadYomitanExtensionHandler({ loadYomitanExtensionCore: async (options) => { calls.push(`path:${options.userDataPath}`); + calls.push(`external:${options.externalProfilePath ?? ''}`); assert.equal(options.getYomitanParserWindow(), parserWindow); options.setYomitanParserWindow(null); options.setYomitanParserReadyPromise(null); options.setYomitanParserInitPromise(null); options.setYomitanExtension(extension); + options.setYomitanSession(null); return extension; }, userDataPath: '/tmp/subminer', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => parserWindow, setYomitanParserWindow: () => calls.push('set-window'), setYomitanParserReadyPromise: () => calls.push('set-ready'), setYomitanParserInitPromise: () => calls.push('set-init'), setYomitanExtension: () => calls.push('set-ext'), + setYomitanSession: () => calls.push('set-session'), }); assert.equal(await loadYomitanExtension(), extension); - assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']); + assert.deepEqual(calls, [ + 'path:/tmp/subminer', + 'external:/tmp/gsm-profile', + 'set-window', + 'set-ready', + 'set-init', + 'set-ext', + 'set-session', + ]); }); test('ensure yomitan loader returns existing extension when available', async () => { diff --git a/src/main/runtime/yomitan-extension-loader.ts b/src/main/runtime/yomitan-extension-loader.ts index e668e8c..23a7b60 100644 --- a/src/main/runtime/yomitan-extension-loader.ts +++ b/src/main/runtime/yomitan-extension-loader.ts @@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext export function createLoadYomitanExtensionHandler(deps: { loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; userDataPath: YomitanExtensionLoaderDeps['userDataPath']; + externalProfilePath?: YomitanExtensionLoaderDeps['externalProfilePath']; getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; + setYomitanSession: YomitanExtensionLoaderDeps['setYomitanSession']; }) { return async (): Promise => { return deps.loadYomitanExtensionCore({ userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); }; } diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index bc0d26e..1e37d98 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 7ecf9ad..60093a6 100644 --- a/src/main/runtime/yomitan-extension-runtime.ts +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -23,11 +23,13 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ loadYomitanExtensionCore: deps.loadYomitanExtensionCore, userDataPath: deps.userDataPath, + externalProfilePath: deps.externalProfilePath, getYomitanParserWindow: deps.getYomitanParserWindow, setYomitanParserWindow: deps.setYomitanParserWindow, setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, setYomitanParserInitPromise: deps.setYomitanParserInitPromise, setYomitanExtension: deps.setYomitanExtension, + setYomitanSession: deps.setYomitanSession, }); const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler( buildLoadYomitanExtensionMainDepsHandler(), diff --git a/src/main/state.ts b/src/main/state.ts index ca2679e..6dd67a7 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow, Extension } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; import type { Keybinding, @@ -143,6 +143,7 @@ export function transitionAnilistUpdateInFlightState( export interface AppState { yomitanExt: Extension | null; + yomitanSession: Session | null; yomitanSettingsWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null; anilistSetupWindow: BrowserWindow | null; @@ -219,6 +220,7 @@ export interface StartupState { export function createAppState(values: AppStateInitialValues): AppState { return { yomitanExt: null, + yomitanSession: null, yomitanSettingsWindow: null, yomitanParserWindow: null, anilistSetupWindow: null, diff --git a/src/types.ts b/src/types.ts index b96621e..5e589ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -413,6 +413,10 @@ export interface AnilistConfig { characterDictionary?: AnilistCharacterDictionaryConfig; } +export interface YomitanConfig { + externalProfilePath?: string; +} + export interface JellyfinConfig { enabled?: boolean; serverUrl?: string; @@ -496,6 +500,7 @@ export interface Config { auto_start_overlay?: boolean; jimaku?: JimakuConfig; anilist?: AnilistConfig; + yomitan?: YomitanConfig; jellyfin?: JellyfinConfig; discordPresence?: DiscordPresenceConfig; ai?: AiConfig; @@ -621,6 +626,9 @@ export interface ResolvedConfig { collapsibleSections: Required; }; }; + yomitan: { + externalProfilePath: string; + }; jellyfin: { enabled: boolean; serverUrl: string;