diff --git a/AGENTS.md b/AGENTS.md index a6112b9..8f7fa58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/main.ts b/src/main.ts index 86e76de..657c847 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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,18 +2362,9 @@ 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), parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), @@ -2405,21 +2398,9 @@ 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(); return { @@ -2564,18 +2545,9 @@ 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({ authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, @@ -3464,88 +3436,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ immersionTrackerStartupMainDeps, }); -const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntimeHandlers< - CliArgs, - StartupState, - ReturnType ->({ - 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 { 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,60 +4630,161 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ registerIpcRuntimeServices, }, }); -const createCliCommandContextHandler = createCliCommandContextFactory({ - appState, - setLogLevel: (level) => setLogLevel(level, 'cli'), - texthookerService, - getResolvedConfig: () => getResolvedConfig(), - openExternal: (url: string) => shell.openExternal(url), - logBrowserOpenError: (url: string, error: unknown) => - logger.error(`Failed to open browser for texthooker URL: ${url}`, error), - showMpvOsd: (text: string) => showMpvOsd(text), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - openFirstRunSetupWindow: () => openFirstRunSetupWindow(), - setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - refreshKnownWordCache: () => refreshKnownWordCache(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetupWindow: () => openAnilistSetupWindow(), - openJellyfinSetupWindow: () => openJellyfinSetupWindow(), - getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), - processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), - generateCharacterDictionary: async (targetPath?: string) => { - const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); - if (disabledReason) { - throw new Error(disabledReason); - } - return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); +const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ + cliCommandContextMainDeps: { + appState, + setLogLevel: (level) => setLogLevel(level, 'cli'), + texthookerService, + getResolvedConfig: () => getResolvedConfig(), + openExternal: (url: string) => shell.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + showMpvOsd: (text: string) => showMpvOsd(text), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + openFirstRunSetupWindow: () => openFirstRunSetupWindow(), + setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + generateCharacterDictionary: async (targetPath?: string) => { + const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); + if (disabledReason) { + throw new Error(disabledReason); + } + return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); + }, + runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), + runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => + runStatsCliCommand(argsFromCommand, source), + runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request), + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => handleCycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => requestAppQuit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + 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), }, - runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), - runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => - runStatsCliCommand(argsFromCommand, source), - runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request), - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => handleCycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - stopApp: () => requestAppQuit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - logInfo: (message: string) => logger.info(message), - logWarn: (message: string) => logger.warn(message), - logError: (message: string, err: unknown) => logger.error(message, err), }); +const { runAndApplyStartupState } = composeHeadlessStartupHandlers< + CliArgs, + StartupState, + ReturnType +>({ + 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({ + composeOverlayWindowHandlers({ createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), isDev, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts new file mode 100644 index 0000000..d81b3f6 --- /dev/null +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -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'); +}); diff --git a/src/main/runtime/composers/cli-startup-composer.ts b/src/main/runtime/composers/cli-startup-composer.ts new file mode 100644 index 0000000..a473c0a --- /dev/null +++ b/src/main/runtime/composers/cli-startup-composer.ts @@ -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[0]; +type CliCommandContext = ReturnType>; +type CliCommandRuntimeHandlerMainDeps = Omit< + Parameters>[0], + 'createCliCommandContext' +>; +type InitialArgsRuntimeHandlerMainDeps = Omit< + Parameters[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, + }; +} diff --git a/src/main/runtime/composers/headless-startup-composer.test.ts b/src/main/runtime/composers/headless-startup-composer.test.ts new file mode 100644 index 0000000..2a2c9eb --- /dev/null +++ b/src/main/runtime/composers/headless-startup-composer.test.ts @@ -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']); +}); diff --git a/src/main/runtime/composers/headless-startup-composer.ts b/src/main/runtime/composers/headless-startup-composer.ts new file mode 100644 index 0000000..033c37f --- /dev/null +++ b/src/main/runtime/composers/headless-startup-composer.ts @@ -0,0 +1,49 @@ +import { createStartupRuntimeHandlers } from '../startup-runtime-handlers'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type StartupRuntimeHandlersDeps = Parameters< + typeof createStartupRuntimeHandlers +>[0]; +type StartupRuntimeHandlers = ReturnType< + typeof createStartupRuntimeHandlers +>; + +export type HeadlessStartupComposerOptions< + TCliArgs, + TStartupState, + TStartupBootstrapRuntimeDeps, +> = ComposerInputs<{ + startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps< + TCliArgs, + TStartupState, + TStartupBootstrapRuntimeDeps + >; +}>; + +export type HeadlessStartupComposerResult< + TCliArgs, + TStartupState, + TStartupBootstrapRuntimeDeps, +> = ComposerOutputs< + Pick< + StartupRuntimeHandlers, + 'appLifecycleRuntimeRunner' | 'runAndApplyStartupState' + > +>; + +export function composeHeadlessStartupHandlers< + TCliArgs, + TStartupState, + TStartupBootstrapRuntimeDeps, +>( + options: HeadlessStartupComposerOptions, +): HeadlessStartupComposerResult { + const { appLifecycleRuntimeRunner, runAndApplyStartupState } = createStartupRuntimeHandlers( + options.startupRuntimeHandlersDeps, + ); + + return { + appLifecycleRuntimeRunner, + runAndApplyStartupState, + }; +} diff --git a/src/main/runtime/composers/index.ts b/src/main/runtime/composers/index.ts index 4506366..922b54d 100644 --- a/src/main/runtime/composers/index.ts +++ b/src/main/runtime/composers/index.ts @@ -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'; diff --git a/src/main/runtime/composers/overlay-window-composer.test.ts b/src/main/runtime/composers/overlay-window-composer.test.ts new file mode 100644 index 0000000..2d5b006 --- /dev/null +++ b/src/main/runtime/composers/overlay-window-composer.test.ts @@ -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' }); +}); diff --git a/src/main/runtime/composers/overlay-window-composer.ts b/src/main/runtime/composers/overlay-window-composer.ts new file mode 100644 index 0000000..348a3eb --- /dev/null +++ b/src/main/runtime/composers/overlay-window-composer.ts @@ -0,0 +1,18 @@ +import { createOverlayWindowRuntimeHandlers } from '../overlay-window-runtime-handlers'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type OverlayWindowRuntimeDeps = + Parameters>[0]; +type OverlayWindowRuntimeHandlers = ReturnType< + typeof createOverlayWindowRuntimeHandlers +>; + +export type OverlayWindowComposerOptions = ComposerInputs>; +export type OverlayWindowComposerResult = + ComposerOutputs>; + +export function composeOverlayWindowHandlers( + options: OverlayWindowComposerOptions, +): OverlayWindowComposerResult { + return createOverlayWindowRuntimeHandlers(options); +} diff --git a/src/main/runtime/setup-window-factory.test.ts b/src/main/runtime/setup-window-factory.test.ts new file mode 100644 index 0000000..74bdc6a --- /dev/null +++ b/src/main/runtime/setup-window-factory.test.ts @@ -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, + }, + }); +}); diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts new file mode 100644 index 0000000..1057c61 --- /dev/null +++ b/src/main/runtime/setup-window-factory.ts @@ -0,0 +1,53 @@ +export function createCreateFirstRunSetupWindowHandler(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(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(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, + }, + }); +}