Harden Yomitan read-only logging and extract overlay options

- Redact skipped Yomitan write log values (paths to basename, titles hidden)
- Extract overlay BrowserWindow option builder for direct unit testing
- Document and test `externalProfilePath` tilde (`~`) home expansion
This commit is contained in:
2026-03-11 20:28:46 -07:00
parent ae44477a69
commit 9cbc3fc335
8 changed files with 122 additions and 39 deletions

View File

@@ -951,7 +951,7 @@ For GameSentenceMiner on Linux, the default overlay profile path is typically `~
| Option | Values | Description | | Option | Values | Description |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `externalProfilePath` | string path | Optional absolute path to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. | | `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
External-profile mode behavior: External-profile mode behavior:

View File

@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path';
import { createResolveContext } from './context'; import { createResolveContext } from './context';
import { applyIntegrationConfig } from './integrations'; import { applyIntegrationConfig } from './integrations';
@@ -139,5 +140,8 @@ test('yomitan externalProfilePath expands leading tilde to the current home dire
applyIntegrationConfig(context); applyIntegrationConfig(context);
assert.equal(context.resolved.yomitan.externalProfilePath, `${homeDir}/.config/gsm_overlay`); assert.equal(
context.resolved.yomitan.externalProfilePath,
path.join(homeDir, '.config', 'gsm_overlay'),
);
}); });

View File

@@ -1,18 +1,27 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import { buildOverlayWindowOptions } from './overlay-window-options';
import path from 'node:path';
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => { test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts'); const options = buildOverlayWindowOptions('visible', {
const source = fs.readFileSync(sourcePath, 'utf8'); isDev: false,
yomitanSession: null,
});
assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m); assert.equal(options.webPreferences?.sandbox, false);
}); });
test('overlay window config uses the provided Yomitan session when available', () => { test('overlay window config uses the provided Yomitan session when available', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts'); const yomitanSession = { id: 'session' } as never;
const source = fs.readFileSync(sourcePath, 'utf8'); const withSession = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession,
});
const withoutSession = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.match(source, /session:\s*options\.yomitanSession\s*\?\?\s*undefined/); assert.equal(withSession.webPreferences?.session, yomitanSession);
assert.equal(withoutSession.webPreferences?.session, undefined);
}); });

View File

@@ -0,0 +1,39 @@
import type { BrowserWindowConstructorOptions, Session } from 'electron';
import * as path from 'path';
import type { OverlayWindowKind } from './overlay-window-input';
export function buildOverlayWindowOptions(
kind: OverlayWindowKind,
options: {
isDev: boolean;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
return {
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`],
},
};
}

View File

@@ -7,6 +7,7 @@ import {
handleOverlayWindowBeforeInputEvent, handleOverlayWindowBeforeInputEvent,
type OverlayWindowKind, type OverlayWindowKind,
} from './overlay-window-input'; } from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>(); const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -81,32 +82,7 @@ export function createOverlayWindow(
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
): BrowserWindow { ): BrowserWindow {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
x: 0,
y: 0,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`],
},
});
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
loadOverlayWindowLayer(window, kind); loadOverlayWindowLayer(window, kind);
@@ -172,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
loadOverlayWindowLayer(window, layer); loadOverlayWindowLayer(window, layer);
} }
export { buildOverlayWindowOptions } from './overlay-window-options';
export type { OverlayWindowKind } from './overlay-window-input'; export type { OverlayWindowKind } from './overlay-window-input';

View File

@@ -376,6 +376,7 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
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 { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import { import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer, shouldForceOverrideYomitanAnkiServer,
@@ -1348,7 +1349,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}, },
importYomitanDictionary: async (zipPath) => { importYomitanDictionary: async (zipPath) => {
if (isYomitanExternalReadOnlyMode()) { if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`importYomitanDictionary(${zipPath})`); logSkippedYomitanWrite(formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath));
return false; return false;
} }
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
@@ -1359,7 +1360,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}, },
deleteYomitanDictionary: async (dictionaryTitle) => { deleteYomitanDictionary: async (dictionaryTitle) => {
if (isYomitanExternalReadOnlyMode()) { if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`deleteYomitanDictionary(${dictionaryTitle})`); logSkippedYomitanWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false; return false;
} }
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
@@ -1370,7 +1373,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
}, },
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (isYomitanExternalReadOnlyMode()) { if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`upsertYomitanDictionarySettings(${dictionaryTitle})`); logSkippedYomitanWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false; return false;
} }
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();

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)})`;
}