mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(dictionary): add Hachidori backend support (#266)
This commit is contained in:
1 parent
e690ffba07
commit
cbaa4626e1
143 files changed
+5564
-305
No files matched your search
@@ -179,6 +179,45 @@ const konosuba = { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba'
|
||||
const towerOfGod = { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God' };
|
||||
const eminence = { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence' };
|
||||
|
||||
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
|
||||
for (const succeeds of [true, false]) {
|
||||
let imported = false;
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: makeTempDir(),
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/replacement.zip',
|
||||
revision: 'new',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [
|
||||
{ title: 'SubMiner Character Dictionary', revision: 'old' },
|
||||
],
|
||||
dictionaryImportReplacesExisting: () => true,
|
||||
importYomitanDictionary: async () => {
|
||||
imported = true;
|
||||
return succeeds;
|
||||
},
|
||||
deleteYomitanDictionary: async () => {
|
||||
assert.fail('must keep the old dictionary until replacement succeeds');
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
if (succeeds) await runtime.runSyncNow();
|
||||
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
|
||||
assert.equal(imported, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
|
||||
const userDataPath = makeTempDir();
|
||||
writeState(userDataPath, {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
waitForYomitanMutationReady?: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
dictionaryImportReplacesExisting?: () => boolean;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
const importTimeoutMs = resolveImportTimeoutMs(
|
||||
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
|
||||
);
|
||||
if (existing !== null) {
|
||||
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
|
||||
await withTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -108,6 +109,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -72,6 +72,7 @@ function buildContextDeps(overrides: Partial<MainDeps> = {}) {
|
||||
runUpdateCommand: noopAsync,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: noopAsync,
|
||||
runYoutubePlaybackFlow: noopAsync,
|
||||
openHachidoriSettings: noop,
|
||||
openYomitanSettings: noop,
|
||||
openConfigSettingsWindow: noop,
|
||||
openSyncUiWindow: noop,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -145,6 +146,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openHachidoriSettings: () => deps.openHachidoriSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
|
||||
|
||||
@@ -52,6 +52,7 @@ function createDeps() {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -135,6 +136,7 @@ export function createCliCommandContext(
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -6,6 +6,11 @@ import path from 'node:path';
|
||||
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../shared/setup-state';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
|
||||
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -763,3 +769,151 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
let dictionaryCount = 0;
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.canFinish, false);
|
||||
assert.equal(initial.externalYomitanConfigured, false);
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
dictionaryCount = 1;
|
||||
const completed = await hachidori.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
|
||||
assert.equal(hachidori.isSetupCompleted(), true);
|
||||
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
|
||||
|
||||
// Switching back never repeats setup for a backend that already finished.
|
||||
const yomitanAgain = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const restored = await yomitanAgain.ensureSetupStateInitialized();
|
||||
assert.equal(restored.state.status, 'completed');
|
||||
assert.equal(restored.state.dictionaryBackend, 'yomitan');
|
||||
assert.equal(yomitanAgain.isSetupCompleted(), true);
|
||||
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
|
||||
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
|
||||
});
|
||||
});
|
||||
|
||||
test('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
detectLegacyMpvPluginCandidates: () => [
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
],
|
||||
});
|
||||
await yomitan.ensureSetupStateInitialized();
|
||||
const reopened = await yomitan.markSetupInProgress();
|
||||
assert.equal(reopened.state.status, 'in_progress');
|
||||
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
|
||||
assert.equal(yomitan.isSetupCompleted(), false);
|
||||
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const switched = await hachidori.ensureSetupStateInitialized();
|
||||
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
|
||||
const restored = await yomitan.getSetupStatus();
|
||||
assert.equal(restored.state.status, 'completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
fs.writeFileSync(
|
||||
getSetupStatePath(configDir),
|
||||
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
|
||||
);
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
const stored = readSetupState(getSetupStatePath(configDir));
|
||||
assert.equal(stored?.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
|
||||
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 2,
|
||||
capabilities: [],
|
||||
};
|
||||
const service = createFirstRunSetupService({
|
||||
configDir: dir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getHachidoriHostStatus: async () => host,
|
||||
getYomitanDictionaryCount: async () => 7,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
let snapshot = await service.getSetupStatus();
|
||||
assert.equal(snapshot.dictionaryCount, 2);
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
|
||||
// The link is still connecting at startup; that must not undo a finished setup.
|
||||
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Connecting' };
|
||||
snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.dictionaryCount, 0);
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(service.isSetupCompleted(), true);
|
||||
host = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 0,
|
||||
capabilities: [],
|
||||
};
|
||||
assert.equal((await service.getSetupStatus()).canFinish, false);
|
||||
// A reachable host with no dictionaries does reopen setup.
|
||||
assert.equal((await service.ensureSetupStateInitialized()).state.status, 'incomplete');
|
||||
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
|
||||
host = { kind: 'local' };
|
||||
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
|
||||
assert.equal((await service.getSetupStatus()).canFinish, true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStateDictionaryBackend,
|
||||
hasCompletedSetupForBackend,
|
||||
getSetupStatePath,
|
||||
isSetupCompleted,
|
||||
readSetupState,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
type SetupState,
|
||||
} from '../../shared/setup-state';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import type {
|
||||
InstalledFirstRunPluginCandidate,
|
||||
LegacyMpvPluginRemovalResult,
|
||||
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
|
||||
}
|
||||
|
||||
export interface SetupStatusSnapshot {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
if (!snapshot.configReady) {
|
||||
return 'Create or provide the config file before finishing setup.';
|
||||
}
|
||||
if (
|
||||
snapshot.hachidoriHost?.kind === 'disconnected' ||
|
||||
snapshot.hachidoriHost?.kind === 'unavailable'
|
||||
) {
|
||||
return snapshot.hachidoriHost.message;
|
||||
}
|
||||
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
|
||||
}
|
||||
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
return `Install at least one ${name} dictionary before finishing setup.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
}): Promise<{
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
}> {
|
||||
const configReady =
|
||||
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
|
||||
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
|
||||
const externalYomitanConfigured =
|
||||
deps.getDictionaryBackend?.() !== 'hachidori' &&
|
||||
(deps.isExternalYomitanConfigured?.() ?? false);
|
||||
|
||||
const hachidoriHost =
|
||||
deps.getDictionaryBackend?.() === 'hachidori'
|
||||
? await deps.getHachidoriHostStatus?.().catch(
|
||||
(error: unknown): HachidoriHostStatus => ({
|
||||
kind: 'unavailable',
|
||||
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
if (hachidoriHost && hachidoriHost.kind !== 'local') {
|
||||
return {
|
||||
configReady,
|
||||
externalYomitanConfigured: false,
|
||||
hachidoriHost,
|
||||
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (configReady && externalYomitanConfigured) {
|
||||
return {
|
||||
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
|
||||
|
||||
return {
|
||||
configReady,
|
||||
hachidoriHost,
|
||||
dictionaryCount: await deps.getYomitanDictionaryCount(),
|
||||
externalYomitanConfigured,
|
||||
};
|
||||
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
configDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
detectLegacyMpvPluginCandidates?: () =>
|
||||
@@ -283,8 +328,47 @@ export function createFirstRunSetupService(deps: {
|
||||
const isWindows = (deps.platform ?? process.platform) === 'win32';
|
||||
let completed = false;
|
||||
|
||||
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
|
||||
const readStoredState = (): SetupState =>
|
||||
readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
// The file records one backend's status at a time. Project it onto the active
|
||||
// backend: a backend that finished before stays completed, any other stays
|
||||
// incomplete until its own dictionaries are ready.
|
||||
const projectState = (stored: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
|
||||
// Legacy files carry their completion only as the recorded status; keep it.
|
||||
const storedBackend = getSetupStateDictionaryBackend(stored);
|
||||
const completedDictionaryBackends = [
|
||||
...new Set([
|
||||
...(stored.completedDictionaryBackends ?? []),
|
||||
...(stored.status === 'completed' ? [storedBackend] : []),
|
||||
]),
|
||||
];
|
||||
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
|
||||
return {
|
||||
...stored,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends,
|
||||
status: finishedBefore ? 'completed' : 'incomplete',
|
||||
completedAt: finishedBefore ? stored.completedAt : null,
|
||||
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
|
||||
yomitanSetupMode: finishedBefore ? 'internal' : null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
};
|
||||
};
|
||||
const readState = (): SetupState => projectState(readStoredState());
|
||||
const writeState = (state: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
|
||||
state = {
|
||||
...state,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends:
|
||||
state.status === 'completed'
|
||||
? [...others, backend]
|
||||
: (state.completedDictionaryBackends ?? []),
|
||||
};
|
||||
writeSetupState(setupStatePath, state);
|
||||
completed = state.status === 'completed';
|
||||
deps.onStateChanged?.(state);
|
||||
@@ -292,10 +376,12 @@ export function createFirstRunSetupService(deps: {
|
||||
};
|
||||
|
||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
@@ -314,6 +400,8 @@ export function createFirstRunSetupService(deps: {
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
return {
|
||||
dictionaryBackend: getDictionaryBackend(),
|
||||
hachidoriHost,
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
@@ -353,11 +441,19 @@ export function createFirstRunSetupService(deps: {
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: async () => {
|
||||
const state = readState();
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
const stored = readStoredState();
|
||||
// Persist the active backend stamp so the launcher can tell which backend
|
||||
// the running app gates playback on.
|
||||
const state =
|
||||
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
|
||||
? stored
|
||||
: writeState(projectState(stored));
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const canFinish = isYomitanSetupSatisfied({
|
||||
@@ -365,7 +461,11 @@ export function createFirstRunSetupService(deps: {
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (isSetupCompleted(state) && canFinish) {
|
||||
// A linked host is usually still connecting at startup, so an unreachable host
|
||||
// says nothing about its dictionaries; only a reachable empty host reopens setup.
|
||||
const hostUnreachable =
|
||||
hachidoriHost?.kind === 'disconnected' || hachidoriHost?.kind === 'unavailable';
|
||||
if (isSetupCompleted(state) && (canFinish || (configReady && hostUnreachable))) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
}
|
||||
|
||||
@@ -727,3 +727,51 @@ test('closing completed first-run setup quits app when completion policy allows
|
||||
|
||||
assert.deepEqual(calls, ['set', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
test('Hachidori setup names the active dictionary backend', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
dictionaryBackend: 'hachidori',
|
||||
configReady: true,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
assert.match(html, /Hachidori dictionaries/);
|
||||
assert.match(html, /Open Hachidori Settings/);
|
||||
assert.match(html, /Install at least one Hachidori dictionary/);
|
||||
assert.doesNotMatch(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Link host/);
|
||||
assert.match(html, /<details class="external-host">/);
|
||||
assert.match(html, /Use an external dictionary host/);
|
||||
assert.match(html, /keep the browser and its sharing relay running/);
|
||||
assert.match(html, /You can close its browser management page/);
|
||||
});
|
||||
|
||||
test('setup parses link and unlink actions without losing the host address', () => {
|
||||
const address = 'ws://127.0.0.1:38771/link';
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl(
|
||||
'subminer://first-run-setup?action=link-hachidori-host&address=' +
|
||||
encodeURIComponent(address),
|
||||
),
|
||||
{ action: 'link-hachidori-host', address },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'),
|
||||
{ action: 'unlink-hachidori-host' },
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
@@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
export type FirstRunSetupSubmission =
|
||||
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
|
||||
| {
|
||||
action: 'configure-windows-mpv-shortcuts';
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}
|
||||
| { action: 'link-hachidori-host'; address: string }
|
||||
| {
|
||||
action:
|
||||
| 'unlink-hachidori-host'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
};
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
mpvExecutablePath?: string;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
|
||||
|
||||
export interface FirstRunSetupHtmlModel {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -251,9 +259,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const yomitanMeta = model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
const host = model.hachidoriHost;
|
||||
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
|
||||
const hostAddress = linked ? host.address : '';
|
||||
const hostCard =
|
||||
model.dictionaryBackend === 'hachidori'
|
||||
? `
|
||||
<div class="card block">
|
||||
<strong>Dictionary source</strong>
|
||||
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
|
||||
<details class="external-host"${linked ? ' open' : ''}>
|
||||
<summary>Use an external dictionary host</summary>
|
||||
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
|
||||
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
|
||||
<ul class="meta host-requirements">
|
||||
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
|
||||
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
|
||||
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
|
||||
</ul>
|
||||
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
|
||||
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&address='+encodeURIComponent(address)">
|
||||
<label for="hachidori-host-address">Host address</label>
|
||||
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
|
||||
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
|
||||
<div class="inline-actions">
|
||||
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
|
||||
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
|
||||
</div>
|
||||
</form>
|
||||
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
|
||||
</details>
|
||||
</div>`
|
||||
: '';
|
||||
const yomitanMeta =
|
||||
host?.kind === 'connected'
|
||||
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
|
||||
: host?.kind === 'disconnected'
|
||||
? `Host unavailable: ${host.address}`
|
||||
: model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const yomitanBadgeLabel = model.externalYomitanConfigured
|
||||
? 'External'
|
||||
: model.dictionaryCount >= 1
|
||||
@@ -270,8 +316,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.canFinish
|
||||
? model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
|
||||
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -340,6 +386,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.external-host summary {
|
||||
cursor: pointer;
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.external-host[open] summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.host-requirements {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.host-requirements li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.shortcut-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -468,17 +529,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
<strong>${dictionaryName} dictionaries</strong>
|
||||
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||
</div>
|
||||
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||
</div>
|
||||
${hostCard}
|
||||
${mpvExecutablePathCard}
|
||||
${windowsShortcutCard}
|
||||
${renderCommandLineLauncherSection(model.commandLineLauncher)}
|
||||
${legacyPluginCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||
@@ -497,6 +559,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
const parsed = new URL(rawUrl);
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'link-hachidori-host' &&
|
||||
action !== 'unlink-hachidori-host' &&
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'remove-legacy-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
@@ -509,6 +573,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'link-hachidori-host') {
|
||||
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
|
||||
}
|
||||
if (action === 'configure-mpv-executable-path') {
|
||||
return {
|
||||
action,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path';
|
||||
|
||||
const wrap = (value: string) =>
|
||||
`motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`;
|
||||
|
||||
test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => {
|
||||
let invoked: { shell: string; args: string[] } | null = null;
|
||||
const value = await readLoginShellPath({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
runShell: async (shell, args) => {
|
||||
invoked = { shell, args };
|
||||
return wrap('/Users/me/.local/bin:/usr/bin');
|
||||
},
|
||||
});
|
||||
assert.equal(value, '/Users/me/.local/bin:/usr/bin');
|
||||
assert.equal(invoked!.shell, '/bin/zsh');
|
||||
assert.equal(invoked!.args[0], '-ilc');
|
||||
});
|
||||
|
||||
test('readLoginShellPath returns null when markers are missing', async () => {
|
||||
const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' });
|
||||
assert.equal(value, null);
|
||||
});
|
||||
|
||||
test('readLoginShellPath rejects relative explicit shells before execution', async () => {
|
||||
for (const shell of ['zsh', './zsh', '']) {
|
||||
await assert.rejects(
|
||||
readLoginShellPath({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
shell,
|
||||
runShell: async () => assert.fail('Invalid shell must not execute'),
|
||||
}),
|
||||
/Login shell must be an absolute path/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('readLoginShellPath falls back through absolute default shells', async () => {
|
||||
const originalUserInfo = os.userInfo;
|
||||
try {
|
||||
for (const shell of ['/bin/bash', 'bash', '', null]) {
|
||||
Object.defineProperty(os, 'userInfo', {
|
||||
value: () => ({ shell, username: 'test', uid: 1000, gid: 1000, homedir: '/Users/test' }),
|
||||
});
|
||||
await readLoginShellPath({
|
||||
env: { SHELL: './zsh' },
|
||||
runShell: async (selectedShell) => {
|
||||
assert.equal(selectedShell, shell === '/bin/bash' ? '/bin/bash' : '/bin/zsh');
|
||||
return wrap('/usr/bin');
|
||||
},
|
||||
});
|
||||
}
|
||||
Object.defineProperty(os, 'userInfo', {
|
||||
value: () => {
|
||||
throw new Error('User lookup failed');
|
||||
},
|
||||
});
|
||||
await readLoginShellPath({
|
||||
env: { SHELL: 'zsh' },
|
||||
runShell: async (shell) => {
|
||||
assert.equal(shell, '/bin/zsh');
|
||||
return wrap('/usr/bin');
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
os.userInfo = originalUserInfo;
|
||||
}
|
||||
});
|
||||
|
||||
test('mergePathValues puts login entries first and keeps process-only entries', () => {
|
||||
assert.equal(
|
||||
mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'),
|
||||
'/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' };
|
||||
const applied = await applyLoginShellPath({
|
||||
env,
|
||||
shell: '/bin/zsh',
|
||||
runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'),
|
||||
});
|
||||
assert.equal(applied, true);
|
||||
assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MARKER = '__SUBMINER_LOGIN_PATH__';
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise<string>;
|
||||
|
||||
type LoginShellPathOptions = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
shell?: string;
|
||||
runShell?: RunShell;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const runShellDefault: RunShell = (shell, args, timeoutMs) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
shell,
|
||||
args,
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' },
|
||||
},
|
||||
(error, stdout) => (error ? reject(error) : resolve(stdout)),
|
||||
);
|
||||
child.stdin?.end();
|
||||
});
|
||||
|
||||
function defaultShell(env: NodeJS.ProcessEnv): string {
|
||||
if (env.SHELL && isAbsolute(env.SHELL)) return env.SHELL;
|
||||
try {
|
||||
const shell = os.userInfo().shell;
|
||||
return shell && isAbsolute(shell) ? shell : '/bin/zsh';
|
||||
} catch {
|
||||
return '/bin/zsh';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit
|
||||
* launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible.
|
||||
*/
|
||||
export async function readLoginShellPath(options: LoginShellPathOptions): Promise<string | null> {
|
||||
const shell = options.shell ?? defaultShell(options.env);
|
||||
if (!isAbsolute(shell)) throw new Error('Login shell must be an absolute path');
|
||||
const run = options.runShell ?? runShellDefault;
|
||||
// printenv keeps this shell-agnostic (fish exposes $PATH as a list).
|
||||
const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`;
|
||||
const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const value = stdout.split(MARKER)[1]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */
|
||||
export function mergePathValues(loginPath: string, currentPath: string | undefined): string {
|
||||
const merged: string[] = [];
|
||||
for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) {
|
||||
if (entry && !merged.includes(entry)) merged.push(entry);
|
||||
}
|
||||
return merged.join(':');
|
||||
}
|
||||
|
||||
/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */
|
||||
export async function applyLoginShellPath(options: LoginShellPathOptions): Promise<boolean> {
|
||||
const loginPath = await readLoginShellPath(options);
|
||||
if (!loginPath) return false;
|
||||
options.env.PATH = mergePathValues(loginPath, options.env.PATH);
|
||||
return true;
|
||||
}
|
||||
@@ -372,12 +372,19 @@ test('Windows stages a new runtime version while the prior Bun executable is run
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
|
||||
// Windows can keep bun.exe locked briefly after the exit event; cleanup
|
||||
// skips locked runtimes, so retry the way the next launch would.
|
||||
const firstDirectory = path.dirname(first.bunPath);
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
if (!fs.existsSync(firstDirectory)) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.equal(fs.existsSync(firstDirectory), false);
|
||||
assert.ok(fs.existsSync(expectedSecond.bunPath));
|
||||
});
|
||||
|
||||
|
||||
@@ -95,9 +95,11 @@ async function startActiveMediaTimingReview(
|
||||
decisionTimeoutMs?: number;
|
||||
generateWaveform?: () => Promise<number[]>;
|
||||
play?: () => Promise<void>;
|
||||
paused?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
@@ -106,8 +108,9 @@ async function startActiveMediaTimingReview(
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
requestProperty: async (name) =>
|
||||
name === 'duration' ? 100 : name === 'pause' ? (options.paused ?? true) : null,
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
@@ -138,7 +141,7 @@ async function startActiveMediaTimingReview(
|
||||
maxMediaDuration: options.maxMediaDuration ?? 30,
|
||||
});
|
||||
|
||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls, commands };
|
||||
}
|
||||
|
||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||
@@ -646,6 +649,105 @@ test('collectMediaTimingContextLines falls back to played history when no cues a
|
||||
assert.deepEqual(context.next, []);
|
||||
});
|
||||
|
||||
test('media timing review keeps an already-paused video paused when nothing asks to resume', async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('media timing review holds overlay resume requests until the review closes', async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
|
||||
|
||||
assert.equal(runtime.deferPlaybackResume(), true);
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
assert.equal(runtime.deferPlaybackResume(), false);
|
||||
});
|
||||
|
||||
for (const paused of [true, false]) {
|
||||
test(`media timing review stays paused after an overlay pause request (paused before: ${paused})`, async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview({
|
||||
paused,
|
||||
});
|
||||
|
||||
assert.equal(runtime.deferPlaybackResume(), true);
|
||||
runtime.cancelPlaybackResume();
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
}
|
||||
|
||||
test('media timing review honors an overlay pause that arrives while setup reads the pause state', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const pauseReads: Array<(paused: boolean) => void> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: (name) =>
|
||||
name === 'pause'
|
||||
? new Promise((resolve) => pauseReads.push(resolve))
|
||||
: Promise.resolve(name === 'duration' ? 100 : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const request = {
|
||||
kind: 'sentence' as const,
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
};
|
||||
|
||||
const first = runtime.requestReview(request);
|
||||
runtime.cancelPlaybackResume();
|
||||
// mpv answered the read before the overlay's pause reached it.
|
||||
pauseReads[0]!(false);
|
||||
await first;
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
|
||||
commands.length = 0;
|
||||
const second = runtime.requestReview(request);
|
||||
pauseReads[1]!(false);
|
||||
await second;
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||
|
||||
|
||||
@@ -280,6 +280,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
let active: ActiveReview | null = null;
|
||||
let currentRequest: ReviewRequestLifecycle | null = null;
|
||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||
let resumeDeferred = false;
|
||||
/** Set when the overlay pauses during the current request, even before its setup reads the pause state. */
|
||||
let restoreCancelled = false;
|
||||
|
||||
function restorePendingPlayback(): void {
|
||||
const mpvClient = pendingPauseRestore;
|
||||
@@ -289,6 +292,37 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
}
|
||||
|
||||
function resumeDeferredPlayback(): void {
|
||||
if (!resumeDeferred) return;
|
||||
resumeDeferred = false;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds an overlay request to resume playback (e.g. an auto-pause released because the
|
||||
* dictionary popup closed) until the pending review ends, then applies it. Returns false
|
||||
* when no review holds playback, so the caller should resume right away.
|
||||
*/
|
||||
function deferPlaybackResume(): boolean {
|
||||
if (!active && !currentRequest) return false;
|
||||
resumeDeferred = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay asked to pause while a review holds playback, so closing the review must
|
||||
* not resume it: drops both a held overlay resume and the review's own restore.
|
||||
*/
|
||||
function cancelPlaybackResume(): void {
|
||||
if (!currentRequest) return;
|
||||
resumeDeferred = false;
|
||||
restoreCancelled = true;
|
||||
if (active) active.restorePlayback = false;
|
||||
}
|
||||
|
||||
function ensureWindow(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
@@ -413,7 +447,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
|
||||
setup.values;
|
||||
const pauseState = booleanProperty(pauseRaw);
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
// The pause read can predate an overlay pause that arrived during setup.
|
||||
pendingPauseRestore = pauseState === false && !restoreCancelled ? mpvClient : null;
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
if (lifecycle.isCancelled()) {
|
||||
restorePendingPlayback();
|
||||
@@ -531,6 +566,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
const lifecycle = createReviewRequestLifecycle();
|
||||
currentRequest = lifecycle;
|
||||
restoreCancelled = false;
|
||||
try {
|
||||
return await runReview(request, lifecycle);
|
||||
} catch {
|
||||
@@ -541,6 +577,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
} finally {
|
||||
if (currentRequest === lifecycle) {
|
||||
currentRequest = null;
|
||||
resumeDeferredPlayback();
|
||||
}
|
||||
lifecycle.markSettled();
|
||||
}
|
||||
@@ -739,7 +776,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
if (!current) return;
|
||||
deps.clearFrameCache?.();
|
||||
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
|
||||
if (current.restorePlayback && current.mpvClient.connected) {
|
||||
if ((current.restorePlayback || resumeDeferred) && current.mpvClient.connected) {
|
||||
resumeDeferred = false;
|
||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
@@ -760,6 +798,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
getFrame,
|
||||
stopPreview,
|
||||
resolveReview,
|
||||
deferPlaybackResume,
|
||||
cancelPlaybackResume,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w
|
||||
);
|
||||
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`${flag} settings startup skips heavy startup`, () => {
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true);
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false);
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
} from '../../cli/args';
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
@@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunYomitanOnlyStartup(initialArgs) ||
|
||||
(shouldRunDictionarySettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import { createDeps } from '../../core/services/tokenizer/yomitan-scan-test-harness';
|
||||
import { DEFAULT_CONFIG } from '../../config';
|
||||
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
|
||||
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
|
||||
@@ -30,6 +32,7 @@ function createDeferred<T>() {
|
||||
function createRuntimeHarness(
|
||||
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
|
||||
backgroundState: BackgroundStatsServerState | null = null,
|
||||
overrides: Partial<StatsServerRuntimeDeps> = {},
|
||||
) {
|
||||
const appStateValues: Array<StatsServer | null> = [];
|
||||
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
|
||||
@@ -65,10 +68,54 @@ function createRuntimeHarness(
|
||||
removeBackgroundStatsServerState: () => {},
|
||||
isBackgroundStatsServerProcessAlive: () => false,
|
||||
startServer,
|
||||
...overrides,
|
||||
});
|
||||
return { runtime, appStateValues };
|
||||
}
|
||||
|
||||
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
|
||||
const settings: unknown[] = [];
|
||||
const context = vm.createContext({
|
||||
chrome: {},
|
||||
__subminerSyncAnkiSettings: async (value: unknown) => {
|
||||
settings.push(value);
|
||||
return { updated: true, matched: true };
|
||||
},
|
||||
__subminerAddNote: async () => ({ noteId: 123, duplicateNoteIds: [] }),
|
||||
});
|
||||
vm.runInContext('window = globalThis', context);
|
||||
const configs: Parameters<NonNullable<StatsServerRuntimeDeps['startServer']>>[0][] = [];
|
||||
const { runtime } = createRuntimeHarness(
|
||||
async (config) => {
|
||||
configs.push(config);
|
||||
return { close: async () => {} };
|
||||
},
|
||||
null,
|
||||
{
|
||||
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
|
||||
getResolvedConfig: () => ({
|
||||
...DEFAULT_CONFIG,
|
||||
ankiConnect: {
|
||||
...DEFAULT_CONFIG.ankiConnect,
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: {
|
||||
...DEFAULT_CONFIG.ankiConnect.proxy,
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await runtime.ensureStatsServerStarted();
|
||||
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
|
||||
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
|
||||
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
|
||||
await runtime.stopStatsServer();
|
||||
});
|
||||
|
||||
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = createStatsServerRuntime({
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
writeBackgroundStatsServerState,
|
||||
} from './stats-daemon';
|
||||
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './yomitan-anki-server';
|
||||
|
||||
export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||
pid: number;
|
||||
@@ -171,10 +174,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
const ankiUrl = getPreferredYomitanAnkiServerUrl(ankiConnectConfig);
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
|
||||
@@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
||||
});
|
||||
|
||||
function createTrackingController(loadContent: () => string) {
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const events: string[] = [];
|
||||
const cueUpdates: Array<string | null> = [];
|
||||
let serviceCount = 0;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => loadContent(),
|
||||
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
|
||||
createSubtitlePrefetchService: () => {
|
||||
const id = ++serviceCount;
|
||||
return {
|
||||
start: () => events.push(`start:${id}`),
|
||||
stop: () => events.push(`stop:${id}`),
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
};
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues, source) => {
|
||||
cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null);
|
||||
},
|
||||
});
|
||||
|
||||
return { controller, events, cueUpdates };
|
||||
}
|
||||
|
||||
test('re-initializing an unchanged source keeps the running service and republishes cues', async () => {
|
||||
const { controller, events, cueUpdates } = createTrackingController(() => 'content');
|
||||
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 3);
|
||||
|
||||
assert.deepEqual(events, ['start:1']);
|
||||
assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']);
|
||||
});
|
||||
|
||||
test('re-initializing a source whose content changed restarts the service', async () => {
|
||||
let content = 'first';
|
||||
const { controller, events } = createTrackingController(() => content);
|
||||
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
content = 'second';
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
|
||||
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
|
||||
let prefetchedTexts: string[] = [];
|
||||
let publishedTexts: string[] = [];
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => 'content',
|
||||
parseSubtitleCues: (): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 4, text: 'first' },
|
||||
{ startTime: 2, endTime: 6, text: 'second' },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => {
|
||||
prefetchedTexts = cues.map((cue) => cue.text);
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
};
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
publishedTexts = cues?.map((cue) => cue.text) ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
await controller.initSubtitlePrefetch('track.srt', 0);
|
||||
|
||||
assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']);
|
||||
assert.deepEqual(publishedTexts, ['first', 'second']);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||
|
||||
export interface SubtitlePrefetchInitControllerDeps {
|
||||
getCurrentService: () => SubtitlePrefetchService | null;
|
||||
@@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
getCacheGeneration?: () => number;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||
@@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController(
|
||||
deps: SubtitlePrefetchInitControllerDeps,
|
||||
): SubtitlePrefetchInitController {
|
||||
let initRevision = 0;
|
||||
// What the running service was built from. Startup fires several inits for one source
|
||||
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
|
||||
// discard the in-flight tokenization and re-queue the priority window behind it.
|
||||
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
|
||||
|
||||
const stopCurrentService = (): void => {
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
activeSource = null;
|
||||
};
|
||||
|
||||
const cancelPendingInit = (): void => {
|
||||
initRevision += 1;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
stopCurrentService();
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
};
|
||||
|
||||
@@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController(
|
||||
sourceKey = sourcePath,
|
||||
): Promise<void> => {
|
||||
const revision = ++initRevision;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
// The same source may still be current once loaded; keep its service running until then.
|
||||
if (activeSource?.key !== sourceKey) {
|
||||
stopCurrentService();
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeSource?.key === sourceKey &&
|
||||
activeSource.content === content &&
|
||||
deps.getCurrentService()
|
||||
) {
|
||||
// A track change clears the published cues synchronously, so republish them.
|
||||
deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey);
|
||||
return;
|
||||
}
|
||||
stopCurrentService();
|
||||
|
||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
@@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController(
|
||||
return;
|
||||
}
|
||||
|
||||
// Overlap cues only feed the cache; the published cue list stays as authored.
|
||||
const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort(
|
||||
(a, b) => a.startTime - b.startTime,
|
||||
);
|
||||
const nextService = deps.createSubtitlePrefetchService({
|
||||
cues,
|
||||
cues: prefetchCues,
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||
getCacheGeneration: deps.getCacheGeneration,
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
@@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController(
|
||||
}
|
||||
|
||||
deps.setCurrentService(nextService);
|
||||
activeSource = { key: sourceKey, content, cues };
|
||||
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||
nextService.start(currentTimePos);
|
||||
deps.logInfo(
|
||||
@@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController(
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision === initRevision) {
|
||||
stopCurrentService();
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||
|
||||
test('overlapping cues yield the combined line the live path resolves', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 0, endTime: 4, text: 'このまま頑張ったって―' },
|
||||
{ startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), [
|
||||
{ startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('back-to-back cues yield no overlap lines', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 0, endTime: 2, text: 'first' },
|
||||
{ startTime: 2, endTime: 4, text: 'second' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), []);
|
||||
});
|
||||
|
||||
test('each overlap span joins only the cues active across it, in cue-list order', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 3, endTime: 8, text: 'C' },
|
||||
{ startTime: 0, endTime: 6, text: 'A' },
|
||||
{ startTime: 2, endTime: 4, text: 'B' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), [
|
||||
{ startTime: 2, endTime: 3, text: 'A\n\nB' },
|
||||
{ startTime: 3, endTime: 4, text: 'C\n\nA\n\nB' },
|
||||
{ startTime: 4, endTime: 6, text: 'C\n\nA' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||
|
||||
/**
|
||||
* While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live
|
||||
* path tokenizes and caches that combined line as its own entry. Prefetching single cues
|
||||
* never warms it, so overlapping lines (usually the long ones) always miss the cache.
|
||||
*
|
||||
* Returns one synthetic cue per overlap span carrying the text the live path resolves
|
||||
* for it, so the prefetcher can warm those lines in timeline order with the rest.
|
||||
*/
|
||||
export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||||
const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const indices = cues.map((_, index) => index);
|
||||
const byStart = [...indices].sort((a, b) => cues[a]!.startTime - cues[b]!.startTime);
|
||||
const byEnd = [...indices].sort((a, b) => cues[a]!.endTime - cues[b]!.endTime);
|
||||
const singleTexts = new Set(cues.map((cue) => cue.text));
|
||||
const seen = new Set<string>();
|
||||
const overlapCues: SubtitleCue[] = [];
|
||||
|
||||
// Sweep the boundaries once, keeping the cues active over [startTime, endTime). Every
|
||||
// cue edge is a boundary, so a cue is active for a whole interval or not at all.
|
||||
const active = new Set<number>();
|
||||
let nextStart = 0;
|
||||
let nextEnd = 0;
|
||||
for (let i = 0; i + 1 < boundaries.length; i += 1) {
|
||||
const startTime = boundaries[i]!;
|
||||
const endTime = boundaries[i + 1]!;
|
||||
for (; nextEnd < byEnd.length && cues[byEnd[nextEnd]!]!.endTime <= startTime; nextEnd += 1) {
|
||||
active.delete(byEnd[nextEnd]!);
|
||||
}
|
||||
for (
|
||||
;
|
||||
nextStart < byStart.length && cues[byStart[nextStart]!]!.startTime <= startTime;
|
||||
nextStart += 1
|
||||
) {
|
||||
const index = byStart[nextStart]!;
|
||||
// Empty or inverted cues are never on screen.
|
||||
if (cues[index]!.endTime > startTime) active.add(index);
|
||||
}
|
||||
if (active.size < 2) continue;
|
||||
|
||||
// mpv joins simultaneous cues in cue-list order.
|
||||
const liveText = [...active]
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => cues[index]!.text)
|
||||
.join('\n');
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: (startTime + endTime) / 2,
|
||||
cues,
|
||||
});
|
||||
if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
overlapCues.push({ startTime, endTime, text });
|
||||
}
|
||||
|
||||
return overlapCues;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ function buildHandlers(overrides: Partial<TrayTemplateDeps> = {}) {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => false,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createResolveTrayIconPathHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -68,6 +72,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -109,6 +115,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow(true);
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
dictionaryBackend: deps.getDictionaryBackend(),
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -58,6 +62,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -84,6 +90,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
getDictionaryBackend: deps.getDictionaryBackend,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
|
||||
@@ -38,6 +38,8 @@ function baseHandlers(overrides: Partial<TrayMenuHandlers> = {}): TrayMenuHandle
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => undefined,
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -74,6 +76,27 @@ for (const entry of optionalEntries) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime(
|
||||
baseHandlers({
|
||||
dictionaryBackend,
|
||||
openHachidoriSettings: () => calls.push('hachidori'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
}),
|
||||
);
|
||||
const labels = template.flatMap((entry) => (entry.label ? [entry.label] : []));
|
||||
const [shown, hidden] =
|
||||
dictionaryBackend === 'hachidori'
|
||||
? ['Open Hachidori Settings', 'Open Yomitan Settings']
|
||||
: ['Open Yomitan Settings', 'Open Hachidori Settings'];
|
||||
assert.equal(labels.includes(hidden), false);
|
||||
template.find((entry) => entry.label === shown)?.click?.();
|
||||
assert.deepEqual(calls, [dictionaryBackend]);
|
||||
});
|
||||
}
|
||||
|
||||
test('tray menu ends with a separator and Quit', () => {
|
||||
const template = buildTrayMenuTemplateRuntime(baseHandlers());
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function resolveTrayIconPathRuntime(deps: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
@@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -103,8 +107,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
label:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? 'Open Hachidori Settings'
|
||||
: 'Open Yomitan Settings',
|
||||
click:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? handlers.openHachidoriSettings
|
||||
: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open SubMiner Settings',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AnkiConnectConfig, ResolvedConfig } from '../../types';
|
||||
import { createYomitanAnkiServerSyncRuntime } from './yomitan-anki-server-sync';
|
||||
import {
|
||||
buildYomitanAnkiSettingsKey,
|
||||
createYomitanAnkiServerSyncRuntime,
|
||||
} from './yomitan-anki-server-sync';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
|
||||
type SyncCall = { url: string; forceOverride: boolean; deck: string };
|
||||
|
||||
@@ -64,3 +68,15 @@ test('retries the same settings after a failed sync', async () => {
|
||||
await harness.sync();
|
||||
assert.equal(harness.calls.length, 2);
|
||||
});
|
||||
|
||||
test('settings sync key changes when fields or tags change', () => {
|
||||
const key = (word: string, tags: string[]) =>
|
||||
buildYomitanAnkiSettingsKey({
|
||||
targetUrl: 'http://127.0.0.1:8766',
|
||||
targetDeck: 'Mining',
|
||||
forceOverride: true,
|
||||
hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }),
|
||||
});
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner']));
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese']));
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
@@ -18,8 +19,9 @@ export function buildYomitanAnkiSettingsKey(options: {
|
||||
targetUrl: string;
|
||||
targetDeck: string;
|
||||
forceOverride: boolean;
|
||||
hachidoriHints?: ReturnType<typeof buildHachidoriAnkiHints>;
|
||||
}): string {
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`;
|
||||
}
|
||||
|
||||
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||
@@ -47,6 +49,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
targetUrl,
|
||||
targetDeck,
|
||||
forceOverride,
|
||||
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
|
||||
});
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
@@ -66,6 +69,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
{
|
||||
forceOverride,
|
||||
deck: targetDeck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user