refactor: compose startup and setup window wiring

This commit is contained in:
2026-03-27 01:14:58 -07:00
parent 49a582b4fc
commit a3ddfa0641
11 changed files with 604 additions and 202 deletions

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCliStartupHandlers } from './cli-startup-composer';
test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
const handlers = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState: {} as never,
setLogLevel: () => {},
texthookerService: {} as never,
getResolvedConfig: () => ({}) as never,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: () => {},
initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {},
openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () =>
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => false,
getMultiCopyTimeoutMs: () => 0,
schedule: () => 0 as never,
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
cliCommandRuntimeHandlerMainDeps: {
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
ensureOverlayStartupPrereqs: () => {},
setTexthookerOnlyMode: () => {},
commandNeedsOverlayStartupPrereqs: () => false,
startBackgroundWarmups: () => {},
logInfo: () => {},
},
handleCliCommandRuntimeServiceWithContext: () => {},
},
initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => null,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
},
});
assert.equal(typeof handlers.createCliCommandContext, 'function');
assert.equal(typeof handlers.handleCliCommand, 'function');
assert.equal(typeof handlers.handleInitialArgs, 'function');
});

View File

@@ -0,0 +1,50 @@
import type { CliArgs, CliCommandSource } from '../../../cli/args';
import { createCliCommandContextFactory } from '../cli-command-context-factory';
import { createCliCommandRuntimeHandler } from '../cli-command-runtime-handler';
import { createInitialArgsRuntimeHandler } from '../initial-args-runtime-handler';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type CliCommandContextMainDeps = Parameters<typeof createCliCommandContextFactory>[0];
type CliCommandContext = ReturnType<ReturnType<typeof createCliCommandContextFactory>>;
type CliCommandRuntimeHandlerMainDeps = Omit<
Parameters<typeof createCliCommandRuntimeHandler<CliCommandContext>>[0],
'createCliCommandContext'
>;
type InitialArgsRuntimeHandlerMainDeps = Omit<
Parameters<typeof createInitialArgsRuntimeHandler>[0],
'handleCliCommand'
>;
export type CliStartupComposerOptions = ComposerInputs<{
cliCommandContextMainDeps: CliCommandContextMainDeps;
cliCommandRuntimeHandlerMainDeps: CliCommandRuntimeHandlerMainDeps;
initialArgsRuntimeHandlerMainDeps: InitialArgsRuntimeHandlerMainDeps;
}>;
export type CliStartupComposerResult = ComposerOutputs<{
createCliCommandContext: () => CliCommandContext;
handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void;
handleInitialArgs: () => void;
}>;
export function composeCliStartupHandlers(
options: CliStartupComposerOptions,
): CliStartupComposerResult {
const createCliCommandContext = createCliCommandContextFactory(
options.cliCommandContextMainDeps,
);
const handleCliCommand = createCliCommandRuntimeHandler({
...options.cliCommandRuntimeHandlerMainDeps,
createCliCommandContext: () => createCliCommandContext(),
});
const handleInitialArgs = createInitialArgsRuntimeHandler({
...options.initialArgsRuntimeHandlerMainDeps,
handleCliCommand: (args, source) => handleCliCommand(args, source),
});
return {
createCliCommandContext,
handleCliCommand,
handleInitialArgs,
};
}

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeHeadlessStartupHandlers } from './headless-startup-composer';
test('composeHeadlessStartupHandlers returns startup bootstrap handlers', () => {
const calls: string[] = [];
const handlers = composeHeadlessStartupHandlers<
CliArgs,
{ mode: string },
{ startAppLifecycle: (args: CliArgs) => void }
>({
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => (args) => {
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: () => {},
quitApp: () => {},
logGenerateConfigError: () => {},
startAppLifecycle: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: (deps) => {
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
return { mode: 'started' };
},
applyStartupState: (state) => {
calls.push(`apply:${state.mode}`);
},
},
});
assert.equal(typeof handlers.runAndApplyStartupState, 'function');
assert.deepEqual(handlers.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
});

View File

@@ -0,0 +1,49 @@
import { createStartupRuntimeHandlers } from '../startup-runtime-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type StartupRuntimeHandlersDeps<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> = Parameters<
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>[0];
type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> = ReturnType<
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>;
export type HeadlessStartupComposerOptions<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerResult<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export function composeHeadlessStartupHandlers<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
>(
options: HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
): HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> {
const { appLifecycleRuntimeRunner, runAndApplyStartupState } = createStartupRuntimeHandlers(
options.startupRuntimeHandlersDeps,
);
return {
appLifecycleRuntimeRunner,
runAndApplyStartupState,
};
}

View File

@@ -1,10 +1,13 @@
export * from './anilist-setup-composer';
export * from './anilist-tracking-composer';
export * from './app-ready-composer';
export * from './cli-startup-composer';
export * from './contracts';
export * from './headless-startup-composer';
export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer';
export * from './overlay-window-composer';
export * from './shortcuts-runtime-composer';
export * from './startup-lifecycle-composer';

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeOverlayWindowHandlers } from './overlay-window-composer';
test('composeOverlayWindowHandlers returns overlay window handlers', () => {
let mainWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null;
const handlers = composeOverlayWindowHandlers<{ kind: string }>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: (window) => {
mainWindow = window;
},
setModalWindow: (window) => {
modalWindow = window;
},
});
assert.deepEqual(handlers.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { kind: 'visible' });
assert.deepEqual(handlers.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' });
});

View File

@@ -0,0 +1,18 @@
import { createOverlayWindowRuntimeHandlers } from '../overlay-window-runtime-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type OverlayWindowRuntimeDeps<TWindow> =
Parameters<typeof createOverlayWindowRuntimeHandlers<TWindow>>[0];
type OverlayWindowRuntimeHandlers<TWindow> = ReturnType<
typeof createOverlayWindowRuntimeHandlers<TWindow>
>;
export type OverlayWindowComposerOptions<TWindow> = ComposerInputs<OverlayWindowRuntimeDeps<TWindow>>;
export type OverlayWindowComposerResult<TWindow> =
ComposerOutputs<OverlayWindowRuntimeHandlers<TWindow>>;
export function composeOverlayWindowHandlers<TWindow>(
options: OverlayWindowComposerOptions<TWindow>,
): OverlayWindowComposerResult<TWindow> {
return createOverlayWindowRuntimeHandlers<TWindow>(options);
}

View File

@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCreateAnilistSetupWindowHandler,
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
} from './setup-window-factory';
test('createCreateFirstRunSetupWindowHandler builds first-run setup window', () => {
let options: Electron.BrowserWindowConstructorOptions | null = null;
const createSetupWindow = createCreateFirstRunSetupWindowHandler({
createBrowserWindow: (nextOptions) => {
options = nextOptions;
return { id: 'first-run' } as never;
},
});
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
});
test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () => {
let options: Electron.BrowserWindowConstructorOptions | null = null;
const createSetupWindow = createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (nextOptions) => {
options = nextOptions;
return { id: 'jellyfin' } as never;
},
});
assert.deepEqual(createSetupWindow(), { id: 'jellyfin' });
assert.deepEqual(options, {
width: 520,
height: 560,
title: 'Jellyfin Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
});
test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => {
let options: Electron.BrowserWindowConstructorOptions | null = null;
const createSetupWindow = createCreateAnilistSetupWindowHandler({
createBrowserWindow: (nextOptions) => {
options = nextOptions;
return { id: 'anilist' } as never;
},
});
assert.deepEqual(createSetupWindow(), { id: 'anilist' });
assert.deepEqual(options, {
width: 1000,
height: 760,
title: 'Anilist Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
});

View File

@@ -0,0 +1,53 @@
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return (): TWindow =>
deps.createBrowserWindow({
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
}
export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return (): TWindow =>
deps.createBrowserWindow({
width: 520,
height: 560,
title: 'Jellyfin Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
}
export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return (): TWindow =>
deps.createBrowserWindow({
width: 1000,
height: 760,
title: 'Anilist Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
}