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

@@ -83,7 +83,6 @@ This project uses Backlog.md MCP for all task and project management activities.
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization

View File

@@ -170,7 +170,6 @@ import {
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
createOverlayWindowRuntimeHandlers,
createTrayRuntimeHandlers,
createOverlayVisibilityRuntime,
createBroadcastRuntimeOptionsChangedHandler,
@@ -235,11 +234,6 @@ import {
createHandleMineSentenceDigitHandler,
createHandleMultiCopyDigitHandler,
} from './main/runtime/domains/mining';
import {
createCliCommandContextFactory,
createInitialArgsRuntimeHandler,
createCliCommandRuntimeHandler,
} from './main/runtime/domains/ipc';
import {
enforceUnsupportedWaylandMode,
forceX11Backend,
@@ -384,9 +378,12 @@ import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
composeAppReadyRuntime,
composeCliStartupHandlers,
composeHeadlessStartupHandlers,
composeIpcRuntimeHandlers,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayWindowHandlers,
composeShortcutRuntimes,
composeStartupLifecycleHandlers,
} from './main/runtime/composers';
@@ -421,6 +418,11 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createCreateAnilistSetupWindowHandler,
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
} from './main/runtime/setup-window-factory';
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
@@ -2360,17 +2362,8 @@ const {
getSetupWindow: () => appState.jellyfinSetupWindow,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: () =>
new BrowserWindow({
width: 520,
height: 560,
title: 'Jellyfin Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
}),
buildSetupFormHtml: (defaultServer, defaultUser) =>
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
@@ -2405,20 +2398,8 @@ const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSe
});
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow,
createSetupWindow: () =>
new BrowserWindow({
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
createSetupWindow: createCreateFirstRunSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
}),
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
@@ -2564,17 +2545,8 @@ const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetu
const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler(
{
maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow,
createSetupWindow: () =>
new BrowserWindow({
width: 1000,
height: 760,
title: 'Anilist Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
createSetupWindow: createCreateAnilistSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
}),
buildAuthorizeUrl: () =>
buildAnilistSetupUrl({
@@ -3464,88 +3436,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
immersionTrackerStartupMainDeps,
});
const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntimeHandlers<
CliArgs,
StartupState,
ReturnType<typeof createStartupBootstrapRuntimeDeps>
>({
appLifecycleRuntimeRunnerMainDeps: {
app: appLifecycleApp,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: appReadyRuntimeRunner,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
},
createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params),
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => {
setLogLevel(level, source);
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => generateDefaultConfigFile(args, options),
setExitCode: (code) => {
process.exitCode = code;
},
quitApp: () => requestAppQuit(),
logGenerateConfigError: (message) => logger.error(message),
startAppLifecycle,
}),
createStartupBootstrapRuntimeDeps: (deps) => createStartupBootstrapRuntimeDeps(deps),
runStartupBootstrapRuntime,
applyStartupState: (startupState) => applyStartupState(appState, startupState),
});
runAndApplyStartupState();
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true });
anilistStateRuntime.refreshRetryQueueState();
}
void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
commandNeedsOverlayStartupPrereqs: (inputArgs) => commandNeedsOverlayStartupPrereqs(inputArgs),
startBackgroundWarmups: () => startBackgroundWarmups(),
logInfo: (message: string) => logger.info(message),
},
createCliCommandContext: () => createCliCommandContextHandler(),
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
});
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
@@ -3590,29 +3480,6 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayWindowsReadyForVisibilityActions();
}
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () =>
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
getMpvClient: () => appState.mpvClient,
commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
logInfo: (message) => logger.info(message),
handleCliCommand: (args, source) => handleCliCommand(args, source),
});
function handleInitialArgs(): void {
handleInitialArgsRuntimeHandler();
}
const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
@@ -4763,7 +4630,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
registerIpcRuntimeServices,
},
});
const createCliCommandContextHandler = createCliCommandContextFactory({
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState,
setLogLevel: (level) => setLogLevel(level, 'cli'),
texthookerService,
@@ -4814,9 +4682,109 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
logInfo: (message: string) => logger.info(message),
logWarn: (message: string) => logger.warn(message),
logError: (message: string, err: unknown) => logger.error(message, err),
},
cliCommandRuntimeHandlerMainDeps: {
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
commandNeedsOverlayStartupPrereqs: (inputArgs) =>
commandNeedsOverlayStartupPrereqs(inputArgs),
startBackgroundWarmups: () => startBackgroundWarmups(),
logInfo: (message: string) => logger.info(message),
},
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
},
initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () =>
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
getMpvClient: () => appState.mpvClient,
commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
logInfo: (message) => logger.info(message),
},
});
const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
CliArgs,
StartupState,
ReturnType<typeof createStartupBootstrapRuntimeDeps>
>({
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: appLifecycleApp,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: appReadyRuntimeRunner,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
},
createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params),
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => {
setLogLevel(level, source);
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => generateDefaultConfigFile(args, options),
setExitCode: (code) => {
process.exitCode = code;
},
quitApp: () => requestAppQuit(),
logGenerateConfigError: (message) => logger.error(message),
startAppLifecycle,
}),
createStartupBootstrapRuntimeDeps: (deps) => createStartupBootstrapRuntimeDeps(deps),
runStartupBootstrapRuntime,
applyStartupState: (startupState) => applyStartupState(appState, startupState),
},
});
runAndApplyStartupState();
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true });
anilistStateRuntime.refreshRetryQueueState();
}
void initializeDiscordPresenceService();
const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } =
createOverlayWindowRuntimeHandlers<BrowserWindow>({
composeOverlayWindowHandlers<BrowserWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev,

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,
},
});
}