mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat(yomitan): add read-only external profile support for shared dictionaries (#18)
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
20
src/main/runtime/character-dictionary-availability.test.ts
Normal file
20
src/main/runtime/character-dictionary-availability.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
10
src/main/runtime/character-dictionary-availability.ts
Normal file
10
src/main/runtime/character-dictionary-availability.ts
Normal 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.';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal file
36
src/main/runtime/yomitan-profile-policy.test.ts
Normal 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);
|
||||
});
|
||||
25
src/main/runtime/yomitan-profile-policy.ts
Normal file
25
src/main/runtime/yomitan-profile-policy.ts
Normal 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`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal file
24
src/main/runtime/yomitan-read-only-log.test.ts
Normal 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>)',
|
||||
);
|
||||
});
|
||||
25
src/main/runtime/yomitan-read-only-log.ts
Normal file
25
src/main/runtime/yomitan-read-only-log.ts
Normal 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)})`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user