Merge remote-tracking branch 'origin/main' into add-hachidori-backend

This commit is contained in:
sudacode committed 2026-10-01 21:48:59 -07:00
commit 4423dd56af
24 files changed
+1064 -183

No files matched your search

+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');
+196
View File
@@ -0,0 +1,196 @@
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.equal(result.details.includes(statePath), true);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 44,
lastElectronVersion: '44.1.0',
});
});
});
test('runtime guard blocks a minor downgrade even when the patch is higher', () => {
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.99',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.equal(result.details.includes(statePath), true);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard allows a patch downgrade while preserving minor downgrade protection', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.7.3' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.7.2',
userDataPath,
});
assert.equal(result.ok, true);
const updatedState = fs.readFileSync(statePath, 'utf8');
assert.deepEqual(JSON.parse(updatedState), {
highestElectronMajor: 43,
lastElectronVersion: '43.7.2',
});
const minorDowngrade = enforceElectronRuntimeGuard({
electronVersion: '43.6.99',
userDataPath,
});
assert.equal(minorDowngrade.ok, false);
if (minorDowngrade.ok) return;
assert.equal(minorDowngrade.title, 'Electron downgrade blocked');
assert.equal(fs.readFileSync(statePath, 'utf8'), updatedState);
});
});
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
const previousState = JSON.stringify({
highestElectronMajor: 43,
lastElectronVersion: '43.7.2',
});
fs.writeFileSync(statePath, previousState, 'utf8');
const result = enforceElectronRuntimeGuard({
electronVersion: '42.11.8',
userDataPath,
supportedElectronMajor: 42,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
});
});
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/);
});
});
+171
View File
@@ -0,0 +1,171 @@
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 compareElectronMajorMinorVersions(
left: ParsedElectronVersion,
right: ParsedElectronVersion,
): number {
return left.major - right.major || left.minor - right.minor;
}
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 ||
compareElectronMajorMinorVersions(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}.`,
`Runtime safety record: ${statePath}.`,
'',
'Opening Chromium storage with an older Electron major or minor version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile. Patch downgrades within the same major and minor version are allowed.',
].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 };
}
@@ -68,61 +68,72 @@ test('preparation failures keep their exit code and never continue to the launch
assert.match(content, /set "ELECTRON_RUN_AS_NODE="/);
});
test('Windows bootstrap prepares once and forwards metacharacter arguments', async (t) => {
if (process.platform !== 'win32') return;
// Cold and cached launches each allow 15 seconds, plus executable copies during setup.
test(
'Windows bootstrap prepares once and forwards metacharacter arguments',
{ timeout: 45_000 },
async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed & App 100% !');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const resourcesPath = path.join(appDirectory, 'resources');
const launcherDirectory = path.join(resourcesPath, 'launcher');
const localAppData = path.join(root, 'Local App Data');
const bootstrapPath = path.join(root, 'subminer.cmd');
const version = '1.2.3-test';
const cachedBunPath = path.join(localAppData, 'SubMiner', 'launcher-runtime', version, 'bun.exe');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(path.join(launcherDirectory, 'version'), version);
fs.writeFileSync(
path.join(launcherDirectory, 'subminer.js'),
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
`const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`,
);
fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath));
const root = workspace(t);
const appDirectory = path.join(root, 'Installed & App 100% !');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const resourcesPath = path.join(appDirectory, 'resources');
const launcherDirectory = path.join(resourcesPath, 'launcher');
const localAppData = path.join(root, 'Local App Data');
const bootstrapPath = path.join(root, 'subminer.cmd');
const version = '1.2.3-test';
const cachedBunPath = path.join(
localAppData,
'SubMiner',
'launcher-runtime',
version,
'bun.exe',
);
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(path.join(launcherDirectory, 'version'), version);
fs.writeFileSync(
path.join(launcherDirectory, 'subminer.js'),
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
`const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`,
);
fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath));
const args = [
'spaces here',
'100%',
'p%TEMP%q',
'bang!',
'a&b',
'x|y',
'<left>',
'caret^',
'say "hi"',
'日本語',
];
const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData };
const first = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(first.exitCode, 0, first.stderr);
assert.ok(fs.existsSync(cachedBunPath));
const firstPayload: unknown = JSON.parse(first.stdout);
assert.deepEqual(firstPayload, {
args,
app: appPath,
resources: resourcesPath,
managed: '1',
});
const args = [
'spaces here',
'100%',
'p%TEMP%q',
'bang!',
'a&b',
'x|y',
'<left>',
'caret^',
'say "hi"',
'日本語',
];
const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData };
const first = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(first.exitCode, 0, first.stderr);
assert.ok(fs.existsSync(cachedBunPath));
const firstPayload: unknown = JSON.parse(first.stdout);
assert.deepEqual(firstPayload, {
args,
app: appPath,
resources: resourcesPath,
managed: '1',
});
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
"throw new Error('cached launch should not prepare');",
);
const second = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(second.exitCode, 0, second.stderr);
const secondPayload: unknown = JSON.parse(second.stdout);
assert.deepEqual(secondPayload, firstPayload);
});
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
"throw new Error('cached launch should not prepare');",
);
const second = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(second.exitCode, 0, second.stderr);
const secondPayload: unknown = JSON.parse(second.stdout);
assert.deepEqual(secondPayload, firstPayload);
},
);
@@ -0,0 +1,133 @@
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,
});
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z');
fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp);
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
safe: true,
previousCount: 3,
});
assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime());
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, '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) => {
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
fs.writeFileSync(statePath, '{}', 'utf8');
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /could not verify Yomitan dictionary storage/);
assert.equal(error.message.includes(statePath), true);
return true;
},
);
});
});
@@ -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 at ${statePath}: ${(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 && normalizedCount !== state?.lastKnownNonEmptyCount) {
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);
}
}