diff --git a/changes/external-yomitan-profile.md b/changes/external-yomitan-profile.md index d845814..d1c6971 100644 --- a/changes/external-yomitan-profile.md +++ b/changes/external-yomitan-profile.md @@ -1,6 +1,6 @@ -type: added +type: changed area: config - 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. -- Fixed default config bootstrap so `config.jsonc` is seeded even when the config directory already exists. +- SubMiner now seeds `config.jsonc` even when the default config directory already exists. diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 6679126..0161580 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -9,6 +9,10 @@ import { resolveExternalYomitanExtensionPath, resolveExistingYomitanExtensionPath, } from './yomitan-extension-paths'; +import { + clearYomitanExtensionRuntimeState, + clearYomitanParserRuntimeState, +} from './yomitan-extension-runtime-state'; const { session } = electron; const logger = createLogger('main:yomitan-extension-loader'); @@ -28,6 +32,22 @@ export interface YomitanExtensionLoaderDeps { export async function loadYomitanExtension( deps: YomitanExtensionLoaderDeps, ): Promise { + const clearRuntimeState = () => + clearYomitanExtensionRuntimeState({ + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + setYomitanExtension: () => deps.setYomitanExtension(null), + setYomitanSession: () => deps.setYomitanSession(null), + }); + const clearParserState = () => + clearYomitanParserRuntimeState({ + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + }); const externalProfilePath = deps.externalProfilePath?.trim() ?? ''; let extPath: string | null = null; let targetSession: Session = session.defaultSession; @@ -38,8 +58,7 @@ export async function loadYomitanExtension( 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); + clearRuntimeState(); return null; } @@ -56,8 +75,7 @@ export async function loadYomitanExtension( 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); + clearRuntimeState(); return null; } @@ -68,13 +86,7 @@ export async function loadYomitanExtension( extPath = extensionCopy.targetDir; } - const parserWindow = deps.getYomitanParserWindow(); - if (parserWindow && !parserWindow.isDestroyed()) { - parserWindow.destroy(); - } - deps.setYomitanParserWindow(null); - deps.setYomitanParserReadyPromise(null); - deps.setYomitanParserInitPromise(null); + clearParserState(); deps.setYomitanSession(targetSession); try { @@ -91,8 +103,7 @@ export async function loadYomitanExtension( } catch (err) { logger.error('Failed to load Yomitan extension:', (err as Error).message); logger.error('Full error:', err); - deps.setYomitanExtension(null); - deps.setYomitanSession(null); + clearRuntimeState(); return null; } } diff --git a/src/core/services/yomitan-extension-runtime-state.test.ts b/src/core/services/yomitan-extension-runtime-state.test.ts new file mode 100644 index 0000000..886c49e --- /dev/null +++ b/src/core/services/yomitan-extension-runtime-state.test.ts @@ -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']); +}); diff --git a/src/core/services/yomitan-extension-runtime-state.ts b/src/core/services/yomitan-extension-runtime-state.ts new file mode 100644 index 0000000..3106593 --- /dev/null +++ b/src/core/services/yomitan-extension-runtime-state.ts @@ -0,0 +1,34 @@ +type ParserWindowLike = { + isDestroyed?: () => boolean; + destroy?: () => void; +} | null; + +export interface YomitanParserRuntimeStateDeps { + getYomitanParserWindow: () => ParserWindowLike; + setYomitanParserWindow: (window: null) => void; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + setYomitanParserInitPromise: (promise: Promise | 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); +} diff --git a/src/main.ts b/src/main.ts index 53024f2..f596490 100644 --- a/src/main.ts +++ b/src/main.ts @@ -374,12 +374,9 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; -import { - getCharacterDictionaryDisabledReason, - isCharacterDictionaryRuntimeEnabled, -} from './main/runtime/character-dictionary-availability'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; +import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, @@ -1332,9 +1329,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt getConfig: () => { const config = getResolvedConfig().anilist.characterDictionary; return { - enabled: - config.enabled && - isCharacterDictionaryRuntimeEnabled(getConfiguredExternalYomitanProfilePath()), + enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(), maxLoaded: config.maxLoaded, profileScope: config.profileScope, }; @@ -1354,8 +1349,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, importYomitanDictionary: async (zipPath) => { - if (isYomitanExternalReadOnlyMode()) { - logSkippedYomitanWrite(formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath)); + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( + formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath), + ); return false; } await ensureYomitanExtensionLoaded(); @@ -1365,8 +1362,8 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, deleteYomitanDictionary: async (dictionaryTitle) => { - if (isYomitanExternalReadOnlyMode()) { - logSkippedYomitanWrite( + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), ); return false; @@ -1378,8 +1375,8 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }); }, upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { - if (isYomitanExternalReadOnlyMode()) { - logSkippedYomitanWrite( + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite( formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), ); return false; @@ -2762,7 +2759,7 @@ const { ); }, scheduleCharacterDictionarySync: () => { - if (!isCharacterDictionaryEnabledForCurrentProcess()) { + if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) { return; } characterDictionaryAutoSyncRuntime.scheduleSync(); @@ -2844,7 +2841,7 @@ const { ), getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled && - isCharacterDictionaryEnabledForCurrentProcess(), + yomitanProfilePolicy.isCharacterDictionaryEnabled(), getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( @@ -3018,7 +3015,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); - if (extension && !isYomitanExternalReadOnlyMode()) { + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -3026,7 +3023,7 @@ async function loadYomitanExtension(): Promise { async function ensureYomitanExtensionLoaded(): Promise { const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - if (extension && !isYomitanExternalReadOnlyMode()) { + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { await syncYomitanDefaultProfileAnkiServer(); } return extension; @@ -3038,28 +3035,6 @@ function getPreferredYomitanAnkiServerUrl(): string { return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); } -function getConfiguredExternalYomitanProfilePath(): string { - return configuredExternalYomitanProfilePath; -} - -function isYomitanExternalReadOnlyMode(): boolean { - return getConfiguredExternalYomitanProfilePath().length > 0; -} - -function isCharacterDictionaryEnabledForCurrentProcess(): boolean { - return isCharacterDictionaryRuntimeEnabled(getConfiguredExternalYomitanProfilePath()); -} - -function getCharacterDictionaryDisabledReasonForCurrentProcess(): string | null { - return getCharacterDictionaryDisabledReason(getConfiguredExternalYomitanProfilePath()); -} - -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, @@ -3080,7 +3055,7 @@ function getYomitanParserRuntimeDeps() { } async function syncYomitanDefaultProfileAnkiServer(): Promise { - if (isYomitanExternalReadOnlyMode()) { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { return; } @@ -3138,7 +3113,7 @@ function initializeOverlayRuntime(): void { } function openYomitanSettings(): boolean { - if (isYomitanExternalReadOnlyMode()) { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { const message = 'Yomitan settings unavailable while using read-only external-profile mode.'; logger.warn( @@ -3557,7 +3532,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), generateCharacterDictionary: async (targetPath?: string) => { - const disabledReason = getCharacterDictionaryDisabledReasonForCurrentProcess(); + const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); if (disabledReason) { throw new Error(disabledReason); } @@ -3650,11 +3625,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); -const configuredExternalYomitanProfilePath = getResolvedConfig().yomitan.externalProfilePath.trim(); +const yomitanProfilePolicy = createYomitanProfilePolicy({ + externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, + logInfo: (message) => logger.info(message), +}); +const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; const yomitanExtensionRuntime = createYomitanExtensionRuntime({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, - externalProfilePath: getConfiguredExternalYomitanProfilePath(), + externalProfilePath: configuredExternalYomitanProfilePath, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window as BrowserWindow | null; diff --git a/src/main/runtime/yomitan-profile-policy.test.ts b/src/main/runtime/yomitan-profile-policy.test.ts new file mode 100644 index 0000000..0d7be2c --- /dev/null +++ b/src/main/runtime/yomitan-profile-policy.test.ts @@ -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); +}); diff --git a/src/main/runtime/yomitan-profile-policy.ts b/src/main/runtime/yomitan-profile-policy.ts new file mode 100644 index 0000000..7958bfd --- /dev/null +++ b/src/main/runtime/yomitan-profile-policy.ts @@ -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`, + ); + }, + }; +} diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts index 6c59fb2..2695320 100644 --- a/src/main/runtime/yomitan-settings-opener.ts +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -24,11 +24,16 @@ export function createOpenYomitanSettingsHandler(deps: { deps.logWarn('Unable to open Yomitan settings: extension failed to load.'); return; } + const yomitanSession = deps.getYomitanSession?.() ?? null; + if (!yomitanSession) { + deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.'); + return; + } deps.openYomitanSettingsWindow({ yomitanExt: extension, getExistingWindow: deps.getExistingWindow, setWindow: deps.setWindow, - yomitanSession: deps.getYomitanSession?.() ?? null, + yomitanSession, }); })().catch((error) => { deps.logError('Failed to open Yomitan settings window.', error); diff --git a/src/main/runtime/yomitan-settings-runtime.test.ts b/src/main/runtime/yomitan-settings-runtime.test.ts index a14e950..db93ac6 100644 --- a/src/main/runtime/yomitan-settings-runtime.test.ts +++ b/src/main/runtime/yomitan-settings-runtime.test.ts @@ -31,3 +31,28 @@ test('yomitan settings runtime composes opener with built deps', async () => { assert.deepEqual(existingWindow, { id: 'settings' }); 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.']); +});