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 |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `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:

View File

@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as os from 'node:os';
import * as path from 'node:path';
import { createResolveContext } from './context';
import { applyIntegrationConfig } from './integrations';
@@ -139,5 +140,8 @@ test('yomitan externalProfilePath expands leading tilde to the current home dire
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 assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { buildOverlayWindowOptions } from './overlay-window-options';
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
const options = buildOverlayWindowOptions('visible', {
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', () => {
const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
const yomitanSession = { id: 'session' } as never;
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,
type OverlayWindowKind,
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -81,32 +82,7 @@ export function createOverlayWindow(
yomitanSession?: Session | null;
},
): BrowserWindow {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
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}`],
},
});
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
options.ensureOverlayWindowLevel(window);
loadOverlayWindowLayer(window, kind);
@@ -172,4 +148,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
loadOverlayWindowLayer(window, layer);
}
export { buildOverlayWindowOptions } from './overlay-window-options';
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 { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
@@ -1348,7 +1349,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
},
importYomitanDictionary: async (zipPath) => {
if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`importYomitanDictionary(${zipPath})`);
logSkippedYomitanWrite(formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath));
return false;
}
await ensureYomitanExtensionLoaded();
@@ -1359,7 +1360,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
},
deleteYomitanDictionary: async (dictionaryTitle) => {
if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`deleteYomitanDictionary(${dictionaryTitle})`);
logSkippedYomitanWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false;
}
await ensureYomitanExtensionLoaded();
@@ -1370,7 +1373,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (isYomitanExternalReadOnlyMode()) {
logSkippedYomitanWrite(`upsertYomitanDictionarySettings(${dictionaryTitle})`);
logSkippedYomitanWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false;
}
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)})`;
}