blob: 0ec9f05e24d6ac28ecb598e8282b1d8b7e082163 [file]
import { app } from 'electron';
import { mkdir } from 'node:fs/promises';
import { setActiveProxy } from '@maka/runtime';
import {
resolveBootstrapConnections,
resolveOpenCodeFreeBootstrapMigration,
} from '@maka/core';
import type {
AgentGraphCoordinator,
AgentGraphSupervisorWakeCoordinator,
BotRegistry,
SessionManager,
ShellRunProcessManager,
} from '@maka/runtime';
import type { McpClientManager } from '@maka/mcp';
import { backfillSessionProjects } from '@maka/storage';
import type {
createConnectionStore,
createProjectCatalog,
createSessionStore,
createSettingsStore,
createSqliteArtifactStore,
createSqliteModelCallLedger,
createSqliteTelemetryRepo,
openRuntimeEventPersistence,
} from '@maka/storage';
import type { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
import type { createFileCredentialStore } from './credential-store.js';
import { startConfigFileWatcher, type ConfigFileWatcher } from './config-file-watcher.js';
import { toContractNetworkSettings } from './network-settings-main.js';
import type { resolveE2eFixture } from './e2e-fixture.js';
import type { KeepSystemAwakeController } from './keep-system-awake.js';
import type { createPlanReminderMainService } from './plan-reminders-main.js';
import type { createDailyReviewMainService } from './daily-review-main.js';
import type { createMainAutomationWiring } from './automation-wiring.js';
import type { createMainGoalWiring } from './goal-wiring.js';
import type { createMainWindowController } from './main-window.js';
import type { DesktopExecutionStoreWiring } from './execution-store-wiring.js';
import type { assembleDesktopTools } from './tool-assembly.js';
import type { StreamEvents } from './session-stream.js';
import type { SettingsIpcHandle } from './settings-ipc-main.js';
import type { AppUpdateService } from './app-update-service.js';
import { createAppQuitCoordinator } from './app-quit-coordinator.js';
import { installDesktopShellPresentation } from './desktop-shell-presentation.js';
import { resumeSafeBoundaryContinuationsOnStartup } from './startup-safe-boundary-resume.js';
import { retireCursorSubscriptionCredentials } from './oauth/cursor-subscription-retirement.js';
type AssembledTools = ReturnType<typeof assembleDesktopTools>;
export interface AppLifecycleDeps {
// Whether this run stays out of the developer's way. boot.ts owns the
// condition; the dock icon follows it so window visibility and dock
// presence can never drift apart. A fixture window someone asked to see
// (MAKA_E2E_SHOW_WINDOW) opts out of both together: as an accessory app it
// has no dock tile and no Cmd+Tab entry, so switching away during a manual
// review would leave no way back.
startHidden: boolean;
e2eFixture: ReturnType<typeof resolveE2eFixture>;
userDataDir: string;
workspaceRoot: string;
sessionStore: ReturnType<typeof createSessionStore>;
projectCatalog: ReturnType<typeof createProjectCatalog>;
credentialStore: ReturnType<typeof createFileCredentialStore>;
connectionStore: ReturnType<typeof createConnectionStore>;
settingsStore: ReturnType<typeof createSettingsStore>;
telemetryRepo: ReturnType<typeof createSqliteTelemetryRepo>;
artifactStore: ReturnType<typeof createSqliteArtifactStore>;
modelCallLedger: ReturnType<typeof createSqliteModelCallLedger>;
ensureUsageReady: () => Promise<void>;
keepSystemAwake: KeepSystemAwakeController;
botRegistry: BotRegistry;
planReminders: ReturnType<typeof createPlanReminderMainService>;
dailyReview: ReturnType<typeof createDailyReviewMainService>;
updateService: AppUpdateService;
automationWiring: ReturnType<typeof createMainAutomationWiring>;
goalWiring: ReturnType<typeof createMainGoalWiring>;
computerUse: AssembledTools['computerUse'];
computerUseOverlay: AssembledTools['computerUseOverlay'];
/** The Computer Use mirror, torn down on the same two paths as the cursor. */
computerUsePip: AssembledTools['computerUsePip'];
/**
* Retired at quit, not at window close: the item reports on runs, and a run
* can outlive the window it was started from. Destroying it is also what
* gives back the keep-awake assertion, so it is the last backstop against
* leaving one held.
*/
computerUseStatusItem: AssembledTools['computerUseStatusItem'];
/**
* Same reason, and one more: `dispose()` makes the guard answer "unlocked"
* forever, so doing it any earlier would silently switch the lock guard off
* for the rest of the process.
*/
computerUseScreenLock: AssembledTools['computerUseScreenLock'];
shellRuns: ShellRunProcessManager;
mcpManager: McpClientManager;
runtimePersistence: Awaited<ReturnType<typeof openRuntimeEventPersistence>>;
executionStoreWiring: DesktopExecutionStoreWiring;
closeWorkflowStores: () => Promise<void>;
mainWindowController: ReturnType<typeof createMainWindowController>;
runtime: SessionManager;
agentGraphCoordinator: AgentGraphCoordinator;
agentGraphSupervisorWakeCoordinator: AgentGraphSupervisorWakeCoordinator;
agentGraphControlStore: ReturnType<typeof createAgentGraphControlStore>;
streamEvents: StreamEvents;
/** Focus-or-create for the main window; stays in boot.ts next to the
* controller and is registered here on `second-instance` / `activate`. */
focusOrCreateMainWindow: (signal: AbortSignal) => void;
emitConnectionListChanged: () => void;
emitSessionsChanged: (reason: 'migrated') => void;
handleExternalSettingsChange: () => Promise<void>;
/** Accessor for the settings IPC handle, which is assigned inside
* boot.ts's `registerIpc()`; teardown disposes it if present. */
getSettingsIpc: () => SettingsIpcHandle | undefined;
}
/**
* Startup / lifecycle cluster extracted from main.ts (arch R6). Pure move of the
* post-`registerIpc()` tail: the `app.whenReady()` startup flow (dock icon,
* fixture seeding, window creation, background startup),
* `runBackgroundStartup` / `ensureBootstrapConnection` /
* `recoverInterruptedSessionsOnStartup`, the `window-all-closed` and `before-quit`
* handlers, and `runBeforeQuitCleanup`. Startup ORDER is the product, so the
* bodies stay behaviorally identical to their in-main.ts originals; every
* process-scoped collaborator is injected. The single-instance lock stays in
* main.ts; the `registerIpc()` anchor stays in boot.ts. Call this once, at
* the same point the inline `app.whenReady()` used to sit (immediately after
* `registerIpc()`).
*/
export function wireAppLifecycle(deps: AppLifecycleDeps): void {
const {
startHidden,
e2eFixture,
userDataDir,
workspaceRoot,
sessionStore,
projectCatalog,
credentialStore,
connectionStore,
settingsStore,
telemetryRepo,
artifactStore,
modelCallLedger,
ensureUsageReady,
keepSystemAwake,
botRegistry,
planReminders,
dailyReview,
updateService,
automationWiring,
goalWiring,
computerUse,
computerUseOverlay,
computerUsePip,
computerUseStatusItem,
computerUseScreenLock,
shellRuns,
mcpManager,
runtimePersistence,
executionStoreWiring,
closeWorkflowStores,
mainWindowController,
runtime,
agentGraphCoordinator,
agentGraphSupervisorWakeCoordinator,
agentGraphControlStore,
streamEvents,
focusOrCreateMainWindow,
emitConnectionListChanged,
emitSessionsChanged,
handleExternalSettingsChange,
getSettingsIpc,
} = deps;
let backgroundStartup: Promise<void> | undefined;
let configWatcher: ConfigFileWatcher | undefined;
const quitCoordinator = createAppQuitCoordinator({
cleanup: runBeforeQuitCleanup,
focusOrCreateWindow: focusOrCreateMainWindow,
onCleanupError: (error) => console.error('[shutdown] cleanup failed:', error),
resumeQuit: () => app.quit(),
});
async function recoverInterruptedSessionsOnStartup(): Promise<void> {
try {
await runtime.recoverInterruptedSessions();
await agentGraphSupervisorWakeCoordinator.recover();
await agentGraphCoordinator.recover();
if (process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME !== '1') return;
await resumeSafeBoundaryContinuationsOnStartup(runtime, streamEvents, console.error);
} catch {
// Best-effort: startup should still reach the renderer so users can inspect
// and repair any remaining local session state.
}
}
async function resolveSessionProjectsOnStartup(): Promise<void> {
try {
const result = await backfillSessionProjects({
sessions: sessionStore,
catalog: projectCatalog,
});
for (const failure of result.failures) {
console.error(`[projects] could not resolve ${failure.cwd}: ${failure.reason}`);
}
if (result.resolved > 0) emitSessionsChanged('migrated');
} catch (error) {
// Best-effort: an unresolved project only affects sidebar grouping, and
// the sessions themselves must still reach the renderer.
console.error('[projects] session project resolution failed:', error);
}
}
async function ensureBootstrapConnection(): Promise<void> {
await mkdir(workspaceRoot, { recursive: true });
const existingConnections = await connectionStore.list();
let migrated = false;
for (const connection of existingConnections) {
const patch = resolveOpenCodeFreeBootstrapMigration(connection);
if (!patch) continue;
const updated = await connectionStore.updateIfUnchanged(
connection.slug,
connection.updatedAt,
patch,
);
migrated ||= updated !== null;
}
if (migrated) emitConnectionListChanged();
if (existingConnections.length > 0) return;
// opencode-free is seeded unconditionally so a fresh install is usable with
// zero credentials; env-keyed providers layer on top and take the default
// when present (Anthropic before OpenAI). See resolveBootstrapConnections.
const seeds = resolveBootstrapConnections({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
});
for (const seed of seeds) {
await connectionStore.create({
slug: seed.slug,
name: seed.name,
providerType: seed.providerType,
defaultModel: seed.defaultModel,
extras: seed.extras,
});
const envApiKey =
seed.providerType === 'anthropic'
? process.env.ANTHROPIC_API_KEY
: seed.providerType === 'openai'
? process.env.OPENAI_API_KEY
: undefined;
if (envApiKey) await credentialStore.setSecret(seed.slug, 'api_key', envApiKey);
if (seed.isDefault) await connectionStore.setDefault(seed.slug);
}
// Bootstrap runs in BACKGROUND startup (#456): the renderer may have
// already seeded its connection list from the onboarding snapshot,
// so push the change or the model picker stays empty until an
// unrelated action refreshes it.
if (seeds.length > 0) emitConnectionListChanged();
}
app.whenReady().then(async () => {
updateService.start();
// The renderer's first IPC calls (session enumeration, settings read,
// connection listing)
// all read from stores that are initialized synchronously at module load,
// so they succeed regardless of whether background startup has
// settled. Any state that background startup mutates is pushed to the
// renderer via the existing `sessions:changed` / `connections:event`
// / `settings:bots:statusChanged` channels, so the UI converges lazily.
// E2E fixture workspaces are wiped and seeded before stores open in
// boot.ts. SQLite keeps live file handles, so resetting the workspace
// here after store construction would detach the canonical database.
const initialWindowSignal = quitCoordinator.getWindowCreationSignal();
if (!initialWindowSignal) return;
// The application menu is app-scoped, not window-scoped: install it once
// here (before the first window, surviving window close on macOS) instead
// of inside createWindow. A null menu on macOS leaves Cmd+Q and the Edit
// roles without any handler; Windows/Linux keep it suppressed so the
// renderer's shell chrome and Ctrl+N / Ctrl+, shortcuts stay untouched.
// Menu commands route to the renderer through one typed channel when a
// window exists, or re-create the window when there is none (quit-phase
// no-op via the coordinator).
installDesktopShellPresentation({
startHidden,
mainWindowController,
focusOrCreateWindow: quitCoordinator.focusOrCreateWindow,
onIconError: (error) =>
console.error('[icon] failed to set dock icon:', error),
});
app.on('second-instance', quitCoordinator.focusOrCreateWindow);
app.on('activate', quitCoordinator.focusOrCreateWindow);
backgroundStartup = runBackgroundStartup();
await mainWindowController.createWindow(initialWindowSignal);
// Keep the process alive until background work settles so schedulers
// / bridges aren't torn down mid-start by a fast window-all-closed.
await backgroundStartup;
});
/**
* Non-critical startup work that must NOT block the first window paint.
*
* `setActiveProxy` must be applied before any network-bearing step
* (`botRegistry.applySettings`); usage readiness opens the operational SQLite
* telemetry authority. Everything here is best-effort and logged on failure —
* none of it should prevent the user from seeing and interacting with the app shell.
*/
async function runBackgroundStartup(): Promise<void> {
// Each step stands alone, because the doc comment above is a promise the
// old shape could not keep: this was a run of bare `await`s, so the first
// rejection skipped every step after it, silently.
//
// A malformed telemetry record must not prevent session recovery, plan
// reminders, the daily review scheduler, the config watcher, or the
// Automation scheduler from starting.
const step = async (name: string, run: () => unknown): Promise<boolean> => {
try {
await run();
return true;
} catch (error) {
console.error(`[startup] ${name} failed; continuing:`, error);
return false;
}
};
// E2e-fixture seeding happens synchronously in `whenReady` before the
// window opens (see there for why); only the real bootstrap runs here.
await step('retired Cursor credentials', () =>
retireCursorSubscriptionCredentials({ userDataDir, credentialStore }),
);
if (!e2eFixture) {
await step('bootstrap connection', () => ensureBootstrapConnection());
}
// The settings read is the one genuine dependency: three steps below take
// their argument from it, and guessing a default for any of them would be
// worse than not running them.
let settings: Awaited<ReturnType<typeof settingsStore.get>> | undefined;
await step('settings read', async () => {
settings = await settingsStore.get();
});
if (settings) {
const resolved = settings;
await step('proxy', () => setActiveProxy(toContractNetworkSettings(resolved.network).proxy));
// Re-hold the power-save blocker at launch if the user left it enabled, so
// scheduled tasks survive machine sleep across restarts.
await step('keep-awake', () => keepSystemAwake.apply(resolved.system.keepSystemAwake));
}
await step('usage readiness', () => ensureUsageReady());
await step('session recovery', () => recoverInterruptedSessionsOnStartup());
// After recovery: an interrupted session must come back before the sidebar
// learns how to group it, and resolution costs a git probe per directory.
await step('project resolution', () => resolveSessionProjectsOnStartup());
let botRegistryReady = false;
if (settings) {
const resolved = settings;
botRegistryReady = await step(
'bot registry',
() => botRegistry.applySettings(resolved.botChat),
);
}
if (botRegistryReady) {
await step('plan reminders', () => planReminders.refreshTimers());
} else {
console.error('[startup] plan reminders not started; bot registry is not ready');
}
await step('daily review scheduler', () => dailyReview.startScheduler());
await step('config watcher', () => {
configWatcher = startConfigFileWatcher(workspaceRoot, {
onConnectionsChanged: () => emitConnectionListChanged(),
onSettingsChanged: () => void handleExternalSettingsChange(),
});
});
await step('automation scheduler', () => automationWiring.scheduler.start());
}
app.on('window-all-closed', () => {
computerUseOverlay.destroyAll();
computerUsePip.destroyAll();
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', quitCoordinator.handleBeforeQuit);
async function runBeforeQuitCleanup(): Promise<void> {
updateService.dispose();
try {
await backgroundStartup;
} catch (error) {
console.error('[shutdown] background startup failed:', error);
}
automationWiring.scheduler.dispose();
goalWiring.coordinator.dispose();
goalWiring.manager.dispose();
configWatcher?.stop();
planReminders.stopTimers();
dailyReview.stopScheduler();
getSettingsIpc()?.dispose();
const results = await Promise.allSettled([
Promise.resolve().then(() => computerUseOverlay.destroyAll()),
Promise.resolve().then(() => computerUsePip.destroyAll()),
Promise.resolve().then(() => computerUseStatusItem.destroy()),
Promise.resolve().then(() => computerUseScreenLock.dispose()),
Promise.resolve().then(() => computerUse.backend?.dispose?.()),
botRegistry.stopAll(),
Promise.resolve(mainWindowController.disposeBrowserViews()),
shellRuns.terminateAll(),
mcpManager.close(),
agentGraphCoordinator.close(),
agentGraphSupervisorWakeCoordinator.close(),
telemetryRepo.close(),
Promise.resolve().then(() => artifactStore.close?.()),
modelCallLedger.close(),
]);
for (const result of results) {
if (result.status === 'rejected') console.error('[shutdown] cleanup failed:', result.reason);
}
try {
await closeWorkflowStores();
} catch (error) {
console.error('[shutdown] cleanup failed:', error);
}
runtimePersistence.close();
executionStoreWiring.close();
agentGraphControlStore.close();
await sessionStore.close?.();
}
}