feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+7 -1
View File
@@ -26,7 +26,10 @@ import {
readSetupState,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
import {
hasLauncherExternalYomitanProfileConfig,
loadLauncherDictionaryBackend,
} from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const configDir = getLauncherConfigDir();
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
dictionaryBackend: loadLauncherDictionaryBackend(),
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
warn: (message) => log('warn', args.logLevel, message),
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
hasLegacyMpvPlugin: () =>
+11 -1
View File
@@ -1,4 +1,5 @@
import { fail } from './log.js';
import type { DictionaryBackend } from '../src/types/config.js';
import type {
Args,
LauncherLoggingConfig,
@@ -100,8 +101,17 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
};
}
export function loadLauncherDictionaryBackend(): DictionaryBackend {
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
? 'hachidori'
: 'yomitan';
}
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
const config = readLauncherMainConfigObject();
return (
config?.dictionaryBackend !== 'hachidori' && readExternalYomitanProfilePath(config) !== null
);
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
+4
View File
@@ -241,6 +241,10 @@ export function applyRootOptionsToArgs(
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
if (options.yomitan === true || options.hachidori === true) {
parsed.appPassthrough = true;
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
}
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+2
View File
@@ -86,6 +86,8 @@ function applyRootOptions(program: Command): void {
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--settings', 'Open settings window')
.option('--yomitan', 'Open Yomitan settings window')
.option('--hachidori', 'Open Hachidori settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
+22 -20
View File
@@ -282,29 +282,31 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher settings option forwards app settings window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
for (const flag of ['--settings', '--yomitan', '--hachidori']) {
test(`launcher ${flag} forwards the matching app settings command`, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--settings'], env);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher([flag], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
});
});
});
}
test('launcher settings command forwards app settings window command', () => {
withTempDir((root) => {
+9
View File
@@ -344,3 +344,12 @@ test('parseArgs requires an explicit logs action', () => {
assert.equal(exit.code, 1);
assert.match(exit.stderr, /Logs command requires -e or --export/);
});
for (const flag of ['--yomitan', '--hachidori']) {
test(`parseArgs forwards ${flag} to the app`, () => {
const parsed = parseArgs([flag], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, [flag]);
assert.equal(parsed.settings, false);
});
}
+104 -2
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
import {
ensureLauncherSetupReady,
resolveLauncherGateBackend,
waitForSetupCompletion,
} from './setup-gate';
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
@@ -295,3 +299,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
assert.equal(result, true);
});
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
let launched = 0;
let polls = 0;
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
readSetupState: () => state,
isExternalYomitanConfigured: () => true,
launchSetupApp: () => {
launched += 1;
},
sleep: async () => {
polls += 1;
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
},
now: () => polls,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
assert.equal(launched, 1);
assert.equal(polls, 1);
});
test('matching Hachidori completion resumes playback without launching setup', async () => {
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
readSetupState: () => ({
...createDefaultSetupState(),
dictionaryBackend: 'hachidori',
status: 'completed',
lastSeenYomitanDictionaryCount: 1,
}),
launchSetupApp: () => assert.fail('setup should not open'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
test('a backend that finished setup earlier passes the gate after switching back', async () => {
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'yomitan',
readSetupState: () => ({
...createDefaultSetupState(),
dictionaryBackend: 'hachidori',
status: 'incomplete',
completedDictionaryBackends: ['yomitan'],
}),
launchSetupApp: () => assert.fail('setup should not open'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
test('gate follows the running app backend until it restarts into the configured one', async () => {
const warnings: string[] = [];
const state = {
...createDefaultSetupState(),
dictionaryBackend: 'yomitan' as const,
status: 'completed' as const,
};
assert.equal(
await resolveLauncherGateBackend({
configuredBackend: 'hachidori',
state,
isAppRunning: async () => true,
warn: (message) => warnings.push(message),
}),
'yomitan',
);
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
assert.equal(
await resolveLauncherGateBackend({
configuredBackend: 'hachidori',
state,
isAppRunning: async () => false,
}),
'hachidori',
);
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
isAppRunning: async () => true,
readSetupState: () => state,
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
+47 -6
View File
@@ -1,7 +1,13 @@
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
import type { DictionaryBackend } from '../src/types/config.js';
import {
getSetupStateDictionaryBackend,
isSetupCompleted,
type SetupState,
} from '../src/shared/setup-state.js';
export async function waitForSetupCompletion(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (isSetupCompleted(state)) {
if (isSetupCompleted(state, deps.dictionaryBackend)) {
return 'completed';
}
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
export async function waitForLegacyMpvPluginPromptResolution(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
initialState?: SetupState | null;
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
const initialCompleted = isSetupCompleted(deps.initialState);
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
const initialCompletedAt = deps.initialState?.completedAt ?? null;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (
isSetupCompleted(state) &&
isSetupCompleted(state, deps.dictionaryBackend) &&
(!initialCompleted || state?.completedAt !== initialCompletedAt)
) {
return 'acknowledged';
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
return 'timeout';
}
/**
* The app pins its dictionary backend at startup while the config file can change
* underneath it. When an app is already running, gate on the backend it recorded
* in the setup state rather than the config value it has not restarted into.
*/
export async function resolveLauncherGateBackend(deps: {
configuredBackend: DictionaryBackend;
state: SetupState | null;
isAppRunning?: () => Promise<boolean>;
warn?: (message: string) => void;
}): Promise<DictionaryBackend> {
const runningBackend = deps.state
? getSetupStateDictionaryBackend(deps.state)
: deps.configuredBackend;
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
return deps.configuredBackend;
}
deps.warn?.(
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
);
return runningBackend;
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
isAppRunning?: () => Promise<boolean>;
warn?: (message: string) => void;
isExternalYomitanConfigured?: () => boolean;
hasLegacyMpvPlugin?: () => boolean;
launchSetupApp: () => void;
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
pollIntervalMs: number;
}): Promise<boolean> {
const initialState = deps.readSetupState();
const dictionaryBackend = await resolveLauncherGateBackend({
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
state: initialState,
isAppRunning: deps.isAppRunning,
warn: deps.warn,
});
let setupLaunched = false;
const launchSetupApp = () => {
if (setupLaunched) return;
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
launchSetupApp();
const result = await waitForLegacyMpvPluginPromptResolution({
readSetupState: deps.readSetupState,
dictionaryBackend,
sleep: deps.sleep,
now: deps.now,
timeoutMs: deps.timeoutMs,
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
}
}
if (deps.isExternalYomitanConfigured?.()) {
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
return true;
}
const stateAfterLegacyPrompt = deps.readSetupState();
if (isSetupCompleted(stateAfterLegacyPrompt)) {
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
return true;
}
launchSetupApp();
const result = await waitForSetupCompletion({
...deps,
dictionaryBackend,
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
});
return result === 'completed';