Harden Yomitan runtime state and profile policy handling

- Centralize external-profile read-only behavior in a shared Yomitan profile policy
- Clear parser/extension runtime state via dedicated helpers on load failures and reloads
- Prevent opening Yomitan settings when the Yomitan session is unavailable
- Add focused runtime-policy and state-clearing regression tests
This commit is contained in:
2026-03-11 22:56:50 -07:00
parent c9d5f6b6e3
commit 6ff89b9227
9 changed files with 220 additions and 60 deletions

View File

@@ -1,6 +1,6 @@
type: added type: changed
area: config area: config
- Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode. - 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. - 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.

View File

@@ -9,6 +9,10 @@ import {
resolveExternalYomitanExtensionPath, resolveExternalYomitanExtensionPath,
resolveExistingYomitanExtensionPath, resolveExistingYomitanExtensionPath,
} from './yomitan-extension-paths'; } from './yomitan-extension-paths';
import {
clearYomitanExtensionRuntimeState,
clearYomitanParserRuntimeState,
} from './yomitan-extension-runtime-state';
const { session } = electron; const { session } = electron;
const logger = createLogger('main:yomitan-extension-loader'); const logger = createLogger('main:yomitan-extension-loader');
@@ -28,6 +32,22 @@ export interface YomitanExtensionLoaderDeps {
export async function loadYomitanExtension( export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps, deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> { ): Promise<Extension | null> {
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() ?? ''; const externalProfilePath = deps.externalProfilePath?.trim() ?? '';
let extPath: string | null = null; let extPath: string | null = null;
let targetSession: Session = session.defaultSession; let targetSession: Session = session.defaultSession;
@@ -38,8 +58,7 @@ export async function loadYomitanExtension(
if (!extPath) { if (!extPath) {
logger.error('External Yomitan extension not found in configured profile path'); logger.error('External Yomitan extension not found in configured profile path');
logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions')); logger.error('Expected unpacked extension at:', path.join(resolvedProfilePath, 'extensions'));
deps.setYomitanExtension(null); clearRuntimeState();
deps.setYomitanSession(null);
return null; return null;
} }
@@ -56,8 +75,7 @@ export async function loadYomitanExtension(
if (!extPath) { if (!extPath) {
logger.error('Yomitan extension not found in any search path'); logger.error('Yomitan extension not found in any search path');
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths); logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
deps.setYomitanExtension(null); clearRuntimeState();
deps.setYomitanSession(null);
return null; return null;
} }
@@ -68,13 +86,7 @@ export async function loadYomitanExtension(
extPath = extensionCopy.targetDir; extPath = extensionCopy.targetDir;
} }
const parserWindow = deps.getYomitanParserWindow(); clearParserState();
if (parserWindow && !parserWindow.isDestroyed()) {
parserWindow.destroy();
}
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
deps.setYomitanSession(targetSession); deps.setYomitanSession(targetSession);
try { try {
@@ -91,8 +103,7 @@ export async function loadYomitanExtension(
} catch (err) { } catch (err) {
logger.error('Failed to load Yomitan extension:', (err as Error).message); logger.error('Failed to load Yomitan extension:', (err as Error).message);
logger.error('Full error:', err); logger.error('Full error:', err);
deps.setYomitanExtension(null); clearRuntimeState();
deps.setYomitanSession(null);
return null; return null;
} }
} }

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
const calls: string[] = [];
const parserWindow = {
isDestroyed: () => false,
destroy: () => {
calls.push('destroy');
},
};
clearYomitanParserRuntimeState({
getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
setYomitanParserReadyPromise: (promise) =>
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
setYomitanParserInitPromise: (promise) =>
calls.push(`init:${promise === null ? 'null' : 'set'}`),
});
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
});
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
const calls: string[] = [];
const parserWindow = {
isDestroyed: () => true,
destroy: () => {
calls.push('destroy');
},
};
clearYomitanParserRuntimeState({
getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
setYomitanParserReadyPromise: (promise) =>
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
setYomitanParserInitPromise: (promise) =>
calls.push(`init:${promise === null ? 'null' : 'set'}`),
});
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
});

View File

@@ -0,0 +1,34 @@
type ParserWindowLike = {
isDestroyed?: () => boolean;
destroy?: () => void;
} | null;
export interface YomitanParserRuntimeStateDeps {
getYomitanParserWindow: () => ParserWindowLike;
setYomitanParserWindow: (window: null) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
}
export interface YomitanExtensionRuntimeStateDeps extends YomitanParserRuntimeStateDeps {
setYomitanExtension: (extension: null) => void;
setYomitanSession: (session: null) => void;
}
export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDeps): void {
const parserWindow = deps.getYomitanParserWindow();
if (parserWindow && !parserWindow.isDestroyed?.()) {
parserWindow.destroy?.();
}
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
}
export function clearYomitanExtensionRuntimeState(
deps: YomitanExtensionRuntimeStateDeps,
): void {
clearYomitanParserRuntimeState(deps);
deps.setYomitanExtension(null);
deps.setYomitanSession(null);
}

View File

@@ -374,12 +374,9 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; 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 { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import { import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
@@ -1332,9 +1329,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
getConfig: () => { getConfig: () => {
const config = getResolvedConfig().anilist.characterDictionary; const config = getResolvedConfig().anilist.characterDictionary;
return { return {
enabled: enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
config.enabled &&
isCharacterDictionaryRuntimeEnabled(getConfiguredExternalYomitanProfilePath()),
maxLoaded: config.maxLoaded, maxLoaded: config.maxLoaded,
profileScope: config.profileScope, profileScope: config.profileScope,
}; };
@@ -1354,8 +1349,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
importYomitanDictionary: async (zipPath) => { importYomitanDictionary: async (zipPath) => {
if (isYomitanExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
logSkippedYomitanWrite(formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath)); yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
);
return false; return false;
} }
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
@@ -1365,8 +1362,8 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
deleteYomitanDictionary: async (dictionaryTitle) => { deleteYomitanDictionary: async (dictionaryTitle) => {
if (isYomitanExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
logSkippedYomitanWrite( yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
); );
return false; return false;
@@ -1378,8 +1375,8 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}); });
}, },
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (isYomitanExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
logSkippedYomitanWrite( yomitanProfilePolicy.logSkippedWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
); );
return false; return false;
@@ -2762,7 +2759,7 @@ const {
); );
}, },
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!isCharacterDictionaryEnabledForCurrentProcess()) { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
return; return;
} }
characterDictionaryAutoSyncRuntime.scheduleSync(); characterDictionaryAutoSyncRuntime.scheduleSync();
@@ -2844,7 +2841,7 @@ const {
), ),
getCharacterDictionaryEnabled: () => getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled && getResolvedConfig().anilist.characterDictionary.enabled &&
isCharacterDictionaryEnabledForCurrentProcess(), yomitanProfilePolicy.isCharacterDictionaryEnabled(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () => getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption( getRuntimeBooleanOption(
@@ -3018,7 +3015,7 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
async function loadYomitanExtension(): Promise<Extension | null> { async function loadYomitanExtension(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.loadYomitanExtension(); const extension = await yomitanExtensionRuntime.loadYomitanExtension();
if (extension && !isYomitanExternalReadOnlyMode()) { if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer(); await syncYomitanDefaultProfileAnkiServer();
} }
return extension; return extension;
@@ -3026,7 +3023,7 @@ async function loadYomitanExtension(): Promise<Extension | null> {
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> { async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
if (extension && !isYomitanExternalReadOnlyMode()) { if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer(); await syncYomitanDefaultProfileAnkiServer();
} }
return extension; return extension;
@@ -3038,28 +3035,6 @@ function getPreferredYomitanAnkiServerUrl(): string {
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); 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() { function getYomitanParserRuntimeDeps() {
return { return {
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
@@ -3080,7 +3055,7 @@ function getYomitanParserRuntimeDeps() {
} }
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> { async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
if (isYomitanExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
return; return;
} }
@@ -3138,7 +3113,7 @@ function initializeOverlayRuntime(): void {
} }
function openYomitanSettings(): boolean { function openYomitanSettings(): boolean {
if (isYomitanExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
const message = const message =
'Yomitan settings unavailable while using read-only external-profile mode.'; 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn( logger.warn(
@@ -3557,7 +3532,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
generateCharacterDictionary: async (targetPath?: string) => { generateCharacterDictionary: async (targetPath?: string) => {
const disabledReason = getCharacterDictionaryDisabledReasonForCurrentProcess(); const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason();
if (disabledReason) { if (disabledReason) {
throw new Error(disabledReason); throw new Error(disabledReason);
} }
@@ -3650,11 +3625,15 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), 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({ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore, loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
externalProfilePath: getConfiguredExternalYomitanProfilePath(), externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => { setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null; appState.yomitanParserWindow = window as BrowserWindow | null;

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createYomitanProfilePolicy } from './yomitan-profile-policy';
test('yomitan profile policy trims external profile path and marks read-only mode', () => {
const calls: string[] = [];
const policy = createYomitanProfilePolicy({
externalProfilePath: ' /tmp/gsm-profile ',
logInfo: (message) => calls.push(message),
});
assert.equal(policy.externalProfilePath, '/tmp/gsm-profile');
assert.equal(policy.isExternalReadOnlyMode(), true);
assert.equal(policy.isCharacterDictionaryEnabled(), false);
assert.equal(
policy.getCharacterDictionaryDisabledReason(),
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
);
policy.logSkippedWrite('importYomitanDictionary(sample.zip)');
assert.deepEqual(calls, [
'[yomitan] skipping importYomitanDictionary(sample.zip): yomitan.externalProfilePath is configured; external profile mode is read-only',
]);
});
test('yomitan profile policy keeps character dictionary enabled without external profile path', () => {
const policy = createYomitanProfilePolicy({
externalProfilePath: ' ',
logInfo: () => undefined,
});
assert.equal(policy.externalProfilePath, '');
assert.equal(policy.isExternalReadOnlyMode(), false);
assert.equal(policy.isCharacterDictionaryEnabled(), true);
assert.equal(policy.getCharacterDictionaryDisabledReason(), null);
});

View File

@@ -0,0 +1,25 @@
import {
getCharacterDictionaryDisabledReason,
isCharacterDictionaryRuntimeEnabled,
} from './character-dictionary-availability';
export function createYomitanProfilePolicy(options: {
externalProfilePath: string;
logInfo: (message: string) => void;
}) {
const externalProfilePath = options.externalProfilePath.trim();
return {
externalProfilePath,
isExternalReadOnlyMode: (): boolean => externalProfilePath.length > 0,
isCharacterDictionaryEnabled: (): boolean =>
isCharacterDictionaryRuntimeEnabled(externalProfilePath),
getCharacterDictionaryDisabledReason: (): string | null =>
getCharacterDictionaryDisabledReason(externalProfilePath),
logSkippedWrite: (action: string): void => {
options.logInfo(
`[yomitan] skipping ${action}: yomitan.externalProfilePath is configured; external profile mode is read-only`,
);
},
};
}

View File

@@ -24,11 +24,16 @@ export function createOpenYomitanSettingsHandler(deps: {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.'); deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return; return;
} }
const yomitanSession = deps.getYomitanSession?.() ?? null;
if (!yomitanSession) {
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
return;
}
deps.openYomitanSettingsWindow({ deps.openYomitanSettingsWindow({
yomitanExt: extension, yomitanExt: extension,
getExistingWindow: deps.getExistingWindow, getExistingWindow: deps.getExistingWindow,
setWindow: deps.setWindow, setWindow: deps.setWindow,
yomitanSession: deps.getYomitanSession?.() ?? null, yomitanSession,
}); });
})().catch((error) => { })().catch((error) => {
deps.logError('Failed to open Yomitan settings window.', error); deps.logError('Failed to open Yomitan settings window.', error);

View File

@@ -31,3 +31,28 @@ test('yomitan settings runtime composes opener with built deps', async () => {
assert.deepEqual(existingWindow, { id: 'settings' }); assert.deepEqual(existingWindow, { id: 'settings' });
assert.deepEqual(calls, ['open-window:session']); 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.']);
});