feat(yomitan): add read-only external profile support for shared dictionaries (#18)

This commit is contained in:
2026-03-12 01:17:34 -07:00
committed by GitHub
parent 68833c76c4
commit 1b56360a24
67 changed files with 1230 additions and 135 deletions

View File

@@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
openYomitanSettingsWindow: ({ yomitanExt }) =>
calls.push(`open:${(yomitanExt as { id: string }).id}`),
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;
calls.push('set-window');
},
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
})();
@@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => {
yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window),
yomitanSession: deps.getYomitanSession(),
});
deps.logWarn('warn');
deps.logError('error', new Error('boom'));
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']);
assert.deepEqual(currentWindow, { id: 'win' });
});

View File

@@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
getYomitanSession?: () => unknown | null;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
@@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
yomitanSession?: unknown | null;
onWindowClosed?: () => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: TWindow | null) => deps.setWindow(window),
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCharacterDictionaryDisabledReason,
isCharacterDictionaryRuntimeEnabled,
} from './character-dictionary-availability';
test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled(''), true);
assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true);
assert.equal(getCharacterDictionaryDisabledReason(''), null);
});
test('character dictionary runtime is disabled when external Yomitan profile is configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false);
assert.equal(
getCharacterDictionaryDisabledReason('/tmp/gsm-profile'),
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
);
});

View File

@@ -0,0 +1,10 @@
export function isCharacterDictionaryRuntimeEnabled(externalProfilePath: string): boolean {
return externalProfilePath.trim().length === 0;
}
export function getCharacterDictionaryDisabledReason(externalProfilePath: string): string | null {
if (isCharacterDictionaryRuntimeEnabled(externalProfilePath)) {
return null;
}
return 'Character dictionary is disabled while yomitan.externalProfilePath is configured.';
}

View File

@@ -143,6 +143,154 @@ test('setup service requires explicit finish for incomplete installs and support
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.completionSource, 'user');
assert.equal(completed.state.yomitanSetupMode, 'internal');
});
});
test('setup service allows completion without internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.yomitanSetupMode, 'external');
assert.equal(completed.dictionaryCount, 0);
});
});
test('setup service does not probe internal dictionaries when external yomitan is configured', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => {
throw new Error('should not probe internal dictionaries in external mode');
},
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.externalYomitanConfigured, true);
assert.equal(snapshot.dictionaryCount, 0);
});
});
test('setup service reopens when external-yomitan completion later has no external profile and no internal dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.state.yomitanSetupMode, null);
assert.equal(snapshot.canFinish, false);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const relaunched = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
isExternalYomitanConfigured: () => false,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const snapshot = await relaunched.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
});
});

View File

@@ -26,6 +26,7 @@ export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
@@ -139,10 +140,50 @@ function getEffectiveWindowsMpvShortcutPreferences(
};
}
function isYomitanSetupSatisfied(options: {
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}): boolean {
if (!options.configReady) {
return false;
}
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
if (configReady && externalYomitanConfigured) {
return {
configReady,
dictionaryCount: 0,
externalYomitanConfigured,
};
}
return {
configReady,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
}
export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
@@ -168,7 +209,12 @@ export function createFirstRunSetupService(deps: {
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
@@ -181,12 +227,15 @@ export function createFirstRunSetupService(deps: {
state,
installedWindowsMpvShortcuts,
);
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
configReady,
dictionaryCount,
canFinish: dictionaryCount >= 1,
canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: {
@@ -217,20 +266,32 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
if (isSetupCompleted(state)) {
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (
isSetupCompleted(state) &&
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
) {
completed = true;
return refreshWithState(state);
}
const dictionaryCount = await deps.getYomitanDictionaryCount();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
if (configReady && dictionaryCount >= 1) {
if (yomitanSetupSatisfied) {
const completedState = writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'legacy_auto_detected',
yomitanSetupMode: externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: dictionaryCount,
});
return buildSnapshot(completedState);
@@ -242,6 +303,7 @@ export function createFirstRunSetupService(deps: {
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: dictionaryCount,
}),
);
@@ -276,6 +338,7 @@ export function createFirstRunSetupService(deps: {
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'user',
yomitanSetupMode: snapshot.externalYomitanConfigured ? 'external' : 'internal',
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
}),
);

View File

@@ -13,6 +13,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
configReady: true,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
@@ -38,6 +39,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: {
@@ -54,6 +56,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.match(html, /Reinstall mpv plugin/);
});
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: true,
externalYomitanConfigured: true,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /External profile configured/);
assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
@@ -117,6 +145,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
configReady: false,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {

View File

@@ -32,6 +32,7 @@ export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: {
@@ -114,6 +115,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
? 'External'
: model.dictionaryCount >= 1
? 'Ready'
: 'Missing';
const yomitanBadgeTone = model.externalYomitanConfigured
? 'ready'
: model.dictionaryCount >= 1
? 'ready'
: 'warn';
const footerMessage = model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
<head>
@@ -257,12 +275,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${windowsShortcutCard}
<div class="actions">
@@ -273,7 +288,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
</main>
</body>
</html>`;

View File

@@ -8,6 +8,7 @@ import {
test('overlay window factory main deps builders return mapped handlers', () => {
const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({
createOverlayWindowCore: (kind) => ({ kind }),
isDev: true,
@@ -18,11 +19,13 @@ test('overlay window factory main deps builders return mapped handlers', () => {
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
});
const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
overlayDeps.forwardTabToMpv();
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: 'visible' | 'modal',
@@ -10,6 +12,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
yomitanSession?: Session | null;
},
) => TWindow;
isDev: boolean;
@@ -20,6 +23,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
getYomitanSession?: () => Session | null;
}) {
return () => ({
createOverlayWindowCore: deps.createOverlayWindowCore,
@@ -31,6 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
});
}

View File

@@ -9,12 +9,14 @@ import {
test('create overlay window handler forwards options and kind', () => {
const calls: string[] = [];
const window = { id: 1 };
const yomitanSession = { id: 'session' } as never;
const createOverlayWindow = createCreateOverlayWindowHandler({
createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`);
assert.equal(options.isDev, true);
assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('modal'), false);
assert.equal(options.yomitanSession, yomitanSession);
options.forwardTabToMpv();
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
@@ -29,6 +31,7 @@ test('create overlay window handler forwards options and kind', () => {
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
});
assert.equal(createOverlayWindow('visible'), window);

View File

@@ -1,3 +1,5 @@
import type { Session } from 'electron';
type OverlayWindowKind = 'visible' | 'modal';
export function createCreateOverlayWindowHandler<TWindow>(deps: {
@@ -12,6 +14,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Session | null;
},
) => TWindow;
isDev: boolean;
@@ -22,6 +25,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
getYomitanSession?: () => Session | null;
}) {
return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, {
@@ -33,6 +37,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
});
};
}

View File

@@ -7,10 +7,14 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
let modalWindow: { kind: string } | null = null;
let debugEnabled = false;
const calls: string[] = [];
const yomitanSession = { id: 'session' } as never;
const runtime = createOverlayWindowRuntimeHandlers({
const runtime = createOverlayWindowRuntimeHandlers<{ kind: string }>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
createOverlayWindowCore: (kind, options) => {
assert.equal(options.yomitanSession, yomitanSession);
return { kind };
},
isDev: true,
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'),
@@ -21,6 +25,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
},
setMainWindow: (window) => {
mainWindow = window;

View File

@@ -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(),

View File

@@ -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 () => {

View File

@@ -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),
});
}

View File

@@ -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 () => {

View File

@@ -4,20 +4,24 @@ import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-ext
export function createLoadYomitanExtensionHandler(deps: {
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
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<Extension | null> => {
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,
});
};
}

View File

@@ -9,6 +9,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
let parserWindow: unknown = null;
let readyPromise: Promise<void> | null = null;
let initPromise: Promise<boolean> | null = null;
let yomitanSession: unknown = null;
let receivedExternalProfilePath = '';
let loadCalls = 0;
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
releaseLoad: null,
@@ -17,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
const runtime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async (options) => {
loadCalls += 1;
receivedExternalProfilePath = options.externalProfilePath ?? '';
options.setYomitanParserWindow(null);
options.setYomitanParserReadyPromise(Promise.resolve());
options.setYomitanParserInitPromise(Promise.resolve(true));
options.setYomitanSession({ id: 'session' } as never);
return await new Promise<Extension | null>((resolve) => {
releaseLoadState.releaseLoad = (value) => {
options.setYomitanExtension(value);
@@ -28,6 +32,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 +46,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 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
assert.equal(parserWindow, null);
assert.ok(readyPromise);
assert.ok(initPromise);
assert.deepEqual(yomitanSession, { id: 'session' });
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
const fakeExtension = { id: 'yomitan' } as Extension;
const releaseLoad = releaseLoadState.releaseLoad;
@@ -74,18 +84,26 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after
test('yomitan extension runtime direct load delegates to core', async () => {
let loadCalls = 0;
let receivedExternalProfilePath = '';
let yomitanSession: unknown = null;
const runtime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async () => {
loadYomitanExtensionCore: async (options) => {
loadCalls += 1;
receivedExternalProfilePath = options.externalProfilePath ?? '';
options.setYomitanSession({ id: 'session' } as never);
return null;
},
userDataPath: '/tmp',
externalProfilePath: '/tmp/gsm-profile',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: (next) => {
yomitanSession = next;
},
getYomitanExtension: () => null,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
@@ -93,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => {
assert.equal(await runtime.loadYomitanExtension(), null);
assert.equal(loadCalls, 1);
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
assert.deepEqual(yomitanSession, { id: 'session' });
});

View File

@@ -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(),

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

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSkippedYomitanWriteAction } from './yomitan-read-only-log';
test('formatSkippedYomitanWriteAction redacts full filesystem paths to basenames', () => {
assert.equal(
formatSkippedYomitanWriteAction('importYomitanDictionary', '/tmp/private/merged.zip'),
'importYomitanDictionary(merged.zip)',
);
});
test('formatSkippedYomitanWriteAction redacts dictionary titles', () => {
assert.equal(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', 'SubMiner Character Dictionary'),
'deleteYomitanDictionary(<redacted>)',
);
});
test('formatSkippedYomitanWriteAction falls back when value is blank', () => {
assert.equal(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', ' '),
'upsertYomitanDictionarySettings(<redacted>)',
);
});

View File

@@ -0,0 +1,25 @@
import * as path from 'path';
function redactSkippedYomitanWriteValue(
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
rawValue: string,
): string {
const trimmed = rawValue.trim();
if (!trimmed) {
return '<redacted>';
}
if (actionName === 'importYomitanDictionary') {
const basename = path.basename(trimmed);
return basename || '<redacted>';
}
return '<redacted>';
}
export function formatSkippedYomitanWriteAction(
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
rawValue: string,
): string {
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
}

View File

@@ -22,14 +22,16 @@ test('yomitan opener warns when extension cannot be loaded', async () => {
});
test('yomitan opener opens settings window when extension is available', async () => {
let opened = false;
let forwardedSession: { id: string } | null | undefined;
const yomitanSession = { id: 'session' };
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => {
opened = true;
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
forwardedSession = nextSession as { id: string } | null;
},
getExistingWindow: () => null,
setWindow: () => {},
getYomitanSession: () => yomitanSession,
logWarn: () => {},
logError: () => {},
});
@@ -37,5 +39,5 @@ test('yomitan opener opens settings window when extension is available', async (
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(opened, true);
assert.equal(forwardedSession, yomitanSession);
});

View File

@@ -1,5 +1,6 @@
type YomitanExtensionLike = unknown;
type BrowserWindowLike = unknown;
type SessionLike = unknown;
export function createOpenYomitanSettingsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
@@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: {
yomitanExt: YomitanExtensionLike;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
yomitanSession?: SessionLike | null;
onWindowClosed?: () => void;
}) => void;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
getYomitanSession?: () => SessionLike | null;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
@@ -21,10 +24,16 @@ export function createOpenYomitanSettingsHandler(deps: {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
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,
});
})().catch((error) => {
deps.logError('Failed to open Yomitan settings window.', error);

View File

@@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime';
test('yomitan settings runtime composes opener with built deps', async () => {
let existingWindow: { id: string } | null = null;
const calls: string[] = [];
const yomitanSession = { id: 'session' };
const runtime = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => {
calls.push('open-window');
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
const current = getExistingWindow();
if (!current) {
setWindow({ id: 'settings' });
@@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => {
setWindow: (window) => {
existingWindow = window as { id: string } | null;
},
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
@@ -27,5 +29,30 @@ test('yomitan settings runtime composes opener with built deps', async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(existingWindow, { id: 'settings' });
assert.deepEqual(calls, ['open-window']);
assert.deepEqual(calls, ['open-window:session']);
});
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
let existingWindow: { id: string } | null = null;
const calls: string[] = [];
const runtime = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => {
calls.push('open-window');
},
getExistingWindow: () => existingWindow as never,
setWindow: (window) => {
existingWindow = window as { id: string } | null;
},
getYomitanSession: () => null,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
runtime.openYomitanSettings();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(existingWindow, null);
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
});