mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user