fix(storage): protect Yomitan profiles across Electron runtime changes

- Block unsupported or downgraded Electron runtimes before loading profile storage
- Isolate development profiles and guard against unexpected dictionary loss
This commit is contained in:
2026-08-18 22:19:47 -07:00
parent 4ed878270f
commit 06b21a68fa
21 changed files with 789 additions and 15 deletions
+2 -2
View File
@@ -20,7 +20,7 @@
"devDependencies": {
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron": "43.4.1",
"electron-builder": "26.15.3",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
@@ -346,7 +346,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron": ["electron@43.4.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
@@ -0,0 +1,6 @@
type: fixed
area: dictionary
- Upgraded the desktop runtime to Electron 43.4.1 and added profile guards that block unsupported runtimes and Electron downgrades before Yomitan storage is loaded.
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
+4
View File
@@ -64,6 +64,10 @@ make dev-watch # watch TS + renderer and launch E
make dev-watch-macos # same as dev-watch, forcing --backend macos
```
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
Always launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
the mpv plugin at runtime:
+8
View File
@@ -109,6 +109,14 @@ If the overlay position is slightly off, right-click and drag on subtitle text t
If you haven't set up dictionaries yet, see [Yomitan setup](/usage#yomitan-setup) first.
**"Electron downgrade blocked" or "Unsupported Electron runtime"**
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron version. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record merely to force an older Electron version to open the profile.
**"Yomitan reported zero dictionaries after previously reporting ..."**
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot normalize or overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
**"Yomitan extension not found in any search path"**
SubMiner bundles Yomitan and searches for it in these locations (in order):
+1
View File
@@ -155,6 +155,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
- `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set.
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
+1 -1
View File
@@ -125,7 +125,7 @@
"devDependencies": {
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron": "43.4.1",
"electron-builder": "26.15.3",
"undici": "7.29.0",
"esbuild": "^0.25.12",
@@ -39,6 +39,7 @@ test('non-macOS modal overlay remains a regular window', () => {
});
assert.equal(options.type, undefined);
assert.equal(options.roundedCorners, false);
});
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
@@ -37,6 +37,7 @@ export function buildOverlayWindowOptions(
paintWhenInitiallyHidden: true,
backgroundColor: '#00000000',
frame: false,
...(platform === 'linux' ? { roundedCorners: false } : {}),
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: shouldAllowCompositorResize,
@@ -67,6 +67,7 @@ export function buildStatsWindowOptions(options: {
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
frame: false,
...(platform === 'linux' ? { roundedCorners: false } : {}),
transparent: false,
alwaysOnTop: true,
resizable: false,
+1
View File
@@ -57,6 +57,7 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
});
assert.equal(options.type, undefined);
assert.equal(options.roundedCorners, false);
});
test('stats panels present after document load on macOS', () => {
+65
View File
@@ -606,3 +606,68 @@ test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', ()
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
});
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
const calls: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => calls.push(`name:${name}`),
setPath: (key, value) => calls.push(`path:${key}:${value}`),
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: () => false,
argv: ['electron', '.', '--start', '--dev'],
env: {},
},
);
assert.equal(userDataPath, '/tmp/xdg/SubMiner-dev');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner-dev']);
});
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
const paths: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: () => {},
setPath: (_key, value) => paths.push(value),
},
{
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
argv: ['electron', '.', '--start'],
env: { XDG_CONFIG_HOME: '/tmp/injected-xdg' },
},
);
assert.equal(userDataPath, '/tmp/injected-xdg/SubMiner');
assert.deepEqual(paths, ['/tmp/injected-xdg/SubMiner']);
});
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
const paths: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: () => {},
setPath: (_key, value) => paths.push(value),
},
{
platform: 'win32',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
homeDir: 'C:\\Users\\tester',
existsSync: () => false,
argv: ['electron.exe', '.', '--debug'],
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
},
);
assert.equal(userDataPath, 'C:\\Users\\tester\\AppData\\Roaming\\SubMiner');
assert.deepEqual(paths, ['C:\\Users\\tester\\AppData\\Roaming\\SubMiner']);
});
+18 -4
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
import { resolveConfigDir } from './config/path-resolution';
@@ -14,6 +15,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner';
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
xdgConfigHome?: string;
homeDir?: string;
existsSync?: (candidate: string) => boolean;
argv?: string[];
env?: NodeJS.ProcessEnv;
};
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
@@ -252,13 +257,22 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
}
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
const userDataPath = resolveConfigDir({
platform: options?.platform ?? process.platform,
appDataDir: options?.appDataDir ?? process.env.APPDATA,
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
const platform = options?.platform ?? process.platform;
const env = options?.env ?? process.env;
const configDir = resolveConfigDir({
platform,
appDataDir: options?.appDataDir ?? env.APPDATA,
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync,
});
const argv = options?.argv ?? process.argv;
const useDevelopmentProfile =
(argv.includes('--dev') || argv.includes('--debug')) && env[USE_PRODUCTION_PROFILE_ENV] !== '1';
const platformPath = platform === 'win32' ? path.win32 : path.posix;
const userDataPath = useDevelopmentProfile
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
: configDir;
app.setName(APP_NAME);
app.setPath('userData', userDataPath);
+13
View File
@@ -35,6 +35,7 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { handleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
applyLogFileTogglesToEnv,
@@ -192,11 +193,23 @@ registerFatalErrorHandlers({
});
function startMainProcess(): void {
// This profile-scoped lock serializes the runtime guard's read-check-write sequence.
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
return;
}
const runtimeGuard = enforceElectronRuntimeGuard({
electronVersion: process.versions.electron ?? '',
userDataPath,
});
if (!runtimeGuard.ok) {
console.error(runtimeGuard.details);
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
app.exit(1);
return;
}
try {
require('./main.js');
} catch (error) {
+12 -1
View File
@@ -36,6 +36,10 @@ import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtim
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import {
assertYomitanDictionaryMutationSafe,
observeYomitanDictionaryCount,
} from './main/runtime/yomitan-dictionary-integrity';
import { startAppControlServer } from './main/runtime/app-control-server';
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
import {
@@ -764,6 +768,7 @@ type BootServices = MainBootServicesResult<
const bootServices = createMainBootServices({
platform: process.platform,
argv: process.argv,
configDir: app.getPath('userData'),
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
@@ -1432,6 +1437,10 @@ const firstRunSetupService = createFirstRunSetupService({
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
if (!integrity.safe) {
logger.error(`[dictionary:integrity] ${integrity.message}`);
}
return dictionaries.length;
},
isExternalYomitanConfigured: () =>
@@ -2523,10 +2532,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
),
getYomitanDictionaryInfo: async () => {
await ensureYomitanExtensionLoaded();
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
return dictionaries;
},
importYomitanDictionary: async (zipPath) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
+53
View File
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
assert.equal(setPathValue, '/tmp/subminer-config');
});
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
const services = createMainBootServices({
platform: 'linux',
argv: ['electron', '.', '--dev'],
configDir: '/tmp/SubMiner-dev',
appDataDir: undefined,
xdgConfigHome: undefined,
homeDir: '/home/tester',
defaultMpvLogFile: '/tmp/default.log',
envMpvLog: undefined,
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/subminer.sock',
resolveConfigDir: () => {
throw new Error('early profile should be authoritative');
},
existsSync: () => false,
mkdirSync: () => {},
joinPath: (...parts) => parts.join('/'),
app: {
setPath: () => {},
quit: () => {},
exit: () => {},
on: () => ({}),
whenReady: async () => {},
},
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => {},
onConfigStartupParseError: () => {},
createConfigService: (configDir) => ({ configDir }),
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
createMainRuntimeRegistry: () => ({}),
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
createOverlayModalInputState: () => ({
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({}),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
createOverlayModalRuntime: () => ({}),
createAppState: (input) => input,
});
assert.equal(services.configDir, '/tmp/SubMiner-dev');
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
});
+10 -7
View File
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
> {
platform: NodeJS.Platform;
argv: string[];
configDir?: string;
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
@@ -174,13 +175,15 @@ export function createMainBootServices<
TAppState,
TAppLifecycleApp
> {
const configDir = params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const configDir =
params.configDir ??
params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const userDataPath = configDir;
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('runtime guard major matches the pinned Electron dependency', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { devDependencies: { electron: string } };
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
});
test('runtime guard records the supported Electron major', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, true);
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard rejects a runtime outside the build major without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '44.0.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Unsupported Electron runtime');
assert.match(result.details, /requires Electron 43/);
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard rejects prerelease Electron versions without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1-beta.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner could not verify Electron');
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.match(result.details, /destroy Yomitan dictionaries/);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 44,
lastElectronVersion: '44.1.0',
});
});
});
test('runtime guard blocks a downgrade within the supported Electron major', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.3.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard fails closed when its safety record is malformed', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(statePath, '{}', 'utf8');
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner profile safety check failed');
assert.match(result.details, /invalid format/);
});
});
+170
View File
@@ -0,0 +1,170 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
export const SUPPORTED_ELECTRON_MAJOR = 43;
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
type ElectronRuntimeState = {
highestElectronMajor: number;
lastElectronVersion: string;
};
type ParsedElectronVersion = {
major: number;
minor: number;
patch: number;
};
type ValidatedElectronRuntimeState = {
state: ElectronRuntimeState;
version: ParsedElectronVersion;
};
export type ElectronRuntimeGuardResult =
| { ok: true; statePath: string }
| { ok: false; title: string; details: string; statePath: string };
function parseElectronVersion(version: string): ParsedElectronVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
if (!match) return null;
const major = Number.parseInt(match[1]!, 10);
const minor = Number.parseInt(match[2]!, 10);
const patch = Number.parseInt(match[3]!, 10);
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
return null;
}
if (major === 0) return null;
return { major, minor, patch };
}
function compareElectronVersions(
left: ParsedElectronVersion,
right: ParsedElectronVersion,
): number {
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
}
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
const highestElectronMajor = parsed.highestElectronMajor;
const lastElectronVersion =
typeof parsed.lastElectronVersion === 'string'
? parseElectronVersion(parsed.lastElectronVersion)
: null;
if (
typeof highestElectronMajor !== 'number' ||
!Number.isSafeInteger(highestElectronMajor) ||
highestElectronMajor <= 0 ||
lastElectronVersion === null
) {
throw new Error('The runtime safety record has an invalid format.');
}
return {
state: {
highestElectronMajor,
lastElectronVersion: parsed.lastElectronVersion!,
},
version: lastElectronVersion,
};
}
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
export function enforceElectronRuntimeGuard(options: {
electronVersion: string;
userDataPath: string;
supportedElectronMajor?: number;
}): ElectronRuntimeGuardResult {
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
const currentVersion = parseElectronVersion(options.electronVersion);
if (currentVersion === null) {
return {
ok: false,
title: 'SubMiner could not verify Electron',
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
statePath,
};
}
if (currentVersion.major !== supportedElectronMajor) {
return {
ok: false,
title: 'Unsupported Electron runtime',
details: [
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
`The current runtime is Electron ${options.electronVersion}.`,
'',
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
let previousState: ValidatedElectronRuntimeState | null;
try {
previousState = readRuntimeState(statePath);
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not read the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
].join('\n'),
statePath,
};
}
if (
previousState &&
(currentVersion.major < previousState.state.highestElectronMajor ||
compareElectronVersions(currentVersion, previousState.version) < 0)
) {
return {
ok: false,
title: 'Electron downgrade blocked',
details: [
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
`The current runtime is Electron ${options.electronVersion}.`,
'',
'Opening Chromium storage with an older Electron version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile.',
].join('\n'),
statePath,
};
}
try {
writeRuntimeState(statePath, {
highestElectronMajor: Math.max(
currentVersion.major,
previousState?.state.highestElectronMajor ?? 0,
),
lastElectronVersion: options.electronVersion,
});
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not update the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
return { ok: true, statePath };
}
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertYomitanDictionaryMutationSafe,
observeYomitanDictionaryCount,
} from './yomitan-dictionary-integrity';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
safe: true,
previousCount: null,
});
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
safe: true,
previousCount: 5,
});
assert.deepEqual(
JSON.parse(
fs.readFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), 'utf8'),
),
{ lastKnownNonEmptyCount: 3 },
);
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
});
});
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: true,
previousCount: null,
});
});
});
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
withTempDir((userDataPath) => {
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
});
}
assert.equal(
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
false,
);
});
});
test('dictionary integrity migrates the previous count from first-run setup state', () => {
withTempDir((userDataPath) => {
fs.writeFileSync(
path.join(userDataPath, 'setup-state.json'),
JSON.stringify({
version: 4,
status: 'completed',
completedAt: '2026-08-18T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 4,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
windowsMpvShortcutLastStatus: 'installed',
bunInstallStatus: 'installed',
launcherInstallStatus: 'installed',
launcherInstallPath: '/home/tester/.local/bin/subminer',
}),
'utf8',
);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 4/,
);
});
});
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
withTempDir((userDataPath) => {
observeYomitanDictionaryCount(userDataPath, 6);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 6/,
);
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: false,
previousCount: 6,
message:
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
});
});
});
test('dictionary integrity fails closed when its state is malformed', () => {
withTempDir((userDataPath) => {
fs.writeFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), '{}', 'utf8');
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
/could not verify Yomitan dictionary storage/,
);
});
});
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
type DictionaryIntegrityState = {
lastKnownNonEmptyCount: number;
};
export type DictionaryIntegrityObservation =
| { safe: true; previousCount: number | null }
| { safe: false; previousCount: number | null; message: string };
function getStatePath(userDataPath: string): string {
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
}
function readState(statePath: string): DictionaryIntegrityState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(
fs.readFileSync(statePath, 'utf8'),
) as Partial<DictionaryIntegrityState>;
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
if (
typeof lastKnownNonEmptyCount !== 'number' ||
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
lastKnownNonEmptyCount <= 0
) {
throw new Error('The dictionary integrity record has an invalid format.');
}
return { lastKnownNonEmptyCount };
}
function writeState(statePath: string, state: DictionaryIntegrityState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
function readLegacySetupCount(userDataPath: string): number | null {
const setupState = readSetupState(getSetupStatePath(userDataPath));
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
? setupState.lastSeenYomitanDictionaryCount
: null;
}
export function observeYomitanDictionaryCount(
userDataPath: string,
dictionaryCount: number,
): DictionaryIntegrityObservation {
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
return {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
};
}
const normalizedCount = dictionaryCount;
const statePath = getStatePath(userDataPath);
let state: DictionaryIntegrityState | null;
try {
state = readState(statePath);
if (state === null) {
const legacyCount = readLegacySetupCount(userDataPath);
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
}
} catch (error) {
return {
safe: false,
previousCount: null,
message: `SubMiner could not verify Yomitan dictionary storage: ${(error as Error).message}`,
};
}
if (normalizedCount === 0 && state !== null) {
return {
safe: false,
previousCount: state.lastKnownNonEmptyCount,
message: [
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
].join(' '),
};
}
if (normalizedCount > 0) {
try {
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
} catch (error) {
return {
safe: false,
previousCount: state?.lastKnownNonEmptyCount ?? null,
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
};
}
}
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
}
export function assertYomitanDictionaryMutationSafe(
userDataPath: string,
dictionaryCount: number,
): void {
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
if (!observation.safe) {
throw new Error(observation.message);
}
}
+46
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
export function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return;
@@ -12,3 +13,48 @@ export function ensureDirForFile(filePath: string): void {
fs.mkdirSync(dir, { recursive: true });
}
}
function syncDirectory(directoryPath: string): void {
// Node cannot open Windows directory handles for fsync. The atomic rename still prevents
// torn files there, while supported platforms also flush the directory entry below.
if (process.platform === 'win32') return;
const directory = fs.openSync(directoryPath, 'r');
try {
fs.fsyncSync(directory);
} finally {
fs.closeSync(directory);
}
}
/** Writes and flushes a sibling temporary file before atomically replacing the target. */
export function writeTextFileAtomicallyDurable(filePath: string, content: string): void {
const directoryPath = path.dirname(filePath);
ensureDir(directoryPath);
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
let temporaryFile: number | undefined;
try {
temporaryFile = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(temporaryFile, content, 'utf8');
fs.fsyncSync(temporaryFile);
fs.closeSync(temporaryFile);
temporaryFile = undefined;
fs.renameSync(temporaryPath, filePath);
syncDirectory(directoryPath);
} catch (error) {
if (temporaryFile !== undefined) {
try {
fs.closeSync(temporaryFile);
} catch {
// Best effort cleanup preserves the original write failure.
}
}
try {
fs.rmSync(temporaryPath, { force: true });
} catch {
// The temporary file is disposable; the existing target stays untouched before rename.
}
throw error;
}
}