/** * Electron Main Process Entry * Manages window creation, system tray, and IPC handlers */ import { app, BrowserWindow, nativeImage, shell } from 'electron'; import type { Server } from 'node:http'; import { join } from 'path'; import { OpencodeManager } from '../opencode/manager'; import { buildOpencodeRuntimeConfigFromNianCodeProviders } from '../opencode/provider-config'; import { PLAYWRIGHT_MCP_SERVER_ID, resolvePlaywrightMcpServer, } from '../opencode/playwright-mcp'; import { resolveOpencodeRuntimePaths } from '../opencode/paths'; import { resolveBundledCourseSkillsDir, resolveBundledSuperpowersDir, } from '../opencode/superpowers'; import { createElectronProjectStorage, createProjectStore, type OpencodeProjectStore, } from '../opencode/project-store'; import { registerIpcHandlers } from './ipc-handlers'; import { createTray } from './tray'; import { createMenu } from './menu'; import { registerZoomShortcuts } from './zoom-shortcuts'; import { appUpdater, registerUpdateHandlers } from './updater'; import { logger } from '../utils/logger'; import { warmupNetworkOptimization } from '../utils/uv-env'; import { resolvePythonRuntime } from '../utils/python-runtime'; import { initTelemetry } from '../utils/telemetry'; import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { clearPendingSecondInstanceFocus, consumeMainWindowReady, createMainWindowFocusState, requestSecondInstanceFocus, } from './main-window-focus'; import { findNianCodeDeepLinkUrl, NIANCODE_APP_PROTOCOL, parseNianCodeDeepLinkUrl, } from './app-deep-link'; import { installMediaPermissionHandler } from './media-permissions'; import { createQuitLifecycleState, markQuitCleanupCompleted, requestQuitLifecycleAction, } from './quit-lifecycle'; import { executableSnapshotChanged, snapshotExecutable, } from './relaunch-on-replaced-app'; import { createSignalQuitHandler } from './signal-quit'; import { acquireProcessInstanceFileLock } from './process-instance-lock'; import { getHostApiToken, startHostApiServer } from '../api/server'; import { HostEventBus } from '../api/event-bus'; import { browserOAuthManager } from '../utils/browser-oauth'; import { createProjectProgressSync } from '../services/project-progress-sync'; import { createWorksCloudDeployment } from '../services/works-cloud-deployment'; import { getPort } from '../utils/config'; import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets'; import { isLocalImageWorkspaceDevelopmentEnabled, LocalImageWorkspace, } from '../image-workspace/local-workspace'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop'; const isE2EMode = process.env.NIANCODE_E2E === '1'; const requestedUserDataDir = process.env.NIANCODE_USER_DATA_DIR?.trim(); if (isE2EMode && requestedUserDataDir) { app.setPath('userData', requestedUserDataDir); } // Disable GPU hardware acceleration globally for maximum stability across // all GPU configurations (no GPU, integrated, discrete). // // Rationale (following VS Code's philosophy): // - Page/file loading is async data fetching — zero GPU dependency. // - The original per-platform GPU branching was added to avoid CPU rendering // competing with sync I/O on Windows, but all file I/O is now async // (fs/promises), so that concern no longer applies. // - Software rendering is deterministic across all hardware; GPU compositing // behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and // driver versions, making it the #1 source of rendering bugs in Electron. // // Users who want GPU acceleration can pass `--enable-gpu` on the CLI or // set `"disable-hardware-acceleration": false` in the app config (future). app.disableHardwareAcceleration(); // On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file. // On Wayland this maps the running window to niancode.desktop (→ icon + app grouping); // on X11 it supplements the StartupWMClass matching. // Must be called before app.whenReady() / before any window is created. if (process.platform === 'linux') { app.setDesktopName('niancode.desktop'); } // Prevent multiple instances of the app from running simultaneously. // Without this, two instances can each spawn their own local runtime process on // the same port. The losing process must exit immediately. const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock(); if (!gotElectronLock) { console.info('[Makelore] Another instance already holds the single-instance lock; exiting duplicate process'); app.exit(0); } let releaseProcessInstanceFileLock: () => void = () => {}; let gotFileLock = true; if (gotElectronLock && !isE2EMode) { try { const fileLock = acquireProcessInstanceFileLock({ userDataDir: app.getPath('userData'), lockName: 'niancode', force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks }); gotFileLock = fileLock.acquired; releaseProcessInstanceFileLock = fileLock.release; if (!fileLock.acquired) { const ownerDescriptor = fileLock.ownerPid ? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}` : fileLock.ownerFormat === 'unknown' ? 'unknown lock format/content' : 'unknown owner'; console.info( `[Makelore] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`, ); app.exit(0); } } catch (error) { console.warn('[Makelore] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error); } } const gotTheLock = gotElectronLock && gotFileLock; const executableSnapshotAtLaunch = snapshotExecutable(process.execPath); // Global references let mainWindow: BrowserWindow | null = null; let opencodeManager!: OpencodeManager; let opencodeProjectStore!: OpencodeProjectStore; let hostEventBus!: HostEventBus; let hostApiServer: Server | null = null; let projectProgressSync: ReturnType | null = null; let worksCloudDeployment: ReturnType | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv); /** * Resolve the icons directory path (works in both dev and packaged mode) */ function getIconsDir(): string { if (app.isPackaged) { // Packaged: icons are in extraResources → process.resourcesPath/resources/icons return join(process.resourcesPath, 'resources', 'icons'); } // Development: relative to dist-electron/main/ return join(__dirname, '../../resources/icons'); } /** * Get the app icon for the current platform */ function getAppIcon(): Electron.NativeImage | undefined { if (process.platform === 'darwin') return undefined; // macOS uses the app bundle icon const iconsDir = getIconsDir(); const iconPath = process.platform === 'win32' ? join(iconsDir, 'icon.ico') : join(iconsDir, 'icon.png'); const icon = nativeImage.createFromPath(iconPath); return icon.isEmpty() ? undefined : icon; } /** * Create the main application window */ function createWindow(): BrowserWindow { const isMac = process.platform === 'darwin'; const isWindows = process.platform === 'win32'; const useCustomTitleBar = isWindows; const shouldSkipSetupForE2E = process.env.NIANCODE_E2E_SKIP_SETUP === '1'; const win = new BrowserWindow({ title: 'Makelore', width: 1280, height: 800, minWidth: 1100, minHeight: 700, icon: getAppIcon(), webPreferences: { preload: join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, sandbox: false, webviewTag: false, }, titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default', trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined, frame: isMac || !useCustomTitleBar, show: false, }); installMediaPermissionHandler(win); registerZoomShortcuts(win); // Handle external links — only allow safe protocols to prevent arbitrary // command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.) win.webContents.setWindowOpenHandler(({ url }) => { try { const parsed = new URL(url); if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { shell.openExternal(url); } else { logger.warn(`Blocked openExternal for disallowed protocol: ${parsed.protocol}`); } } catch { logger.warn(`Blocked openExternal for malformed URL: ${url}`); } return { action: 'deny' }; }); // Load the app if (process.env.VITE_DEV_SERVER_URL) { const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL); if (shouldSkipSetupForE2E) { rendererUrl.searchParams.set('e2eSkipSetup', '1'); } win.loadURL(rendererUrl.toString()); if (!isE2EMode) { win.webContents.openDevTools(); } } else { win.loadFile(join(__dirname, '../../dist/index.html'), { query: shouldSkipSetupForE2E ? { e2eSkipSetup: '1' } : undefined, }); } return win; } function focusWindow(win: BrowserWindow): void { if (win.isDestroyed()) { return; } if (win.isMinimized()) { win.restore(); } win.show(); win.focus(); } function focusMainWindow(): void { if (!mainWindow || mainWindow.isDestroyed()) { return; } clearPendingSecondInstanceFocus(mainWindowFocusState); focusWindow(mainWindow); } function requestMainWindowFocus(reason: string): void { const focusRequest = requestSecondInstanceFocus( mainWindowFocusState, Boolean(mainWindow && !mainWindow.isDestroyed()), ); if (focusRequest === 'focus-now') { focusMainWindow(); return; } logger.debug(`Main window is not ready yet; deferring focus for ${reason}`); } function handleAppDeepLinkActivation(rawUrl: string): boolean { const deepLink = parseNianCodeDeepLinkUrl(rawUrl); if (!deepLink) { return false; } logger.info(`Received Makelore app link: type=${deepLink.type}, request_id=${deepLink.requestId}`); requestMainWindowFocus('app deep link'); return true; } function registerMakeloreProtocolClient(): void { const devEntrypoint = app.isPackaged ? undefined : process.argv[1]; const registered = devEntrypoint ? app.setAsDefaultProtocolClient(NIANCODE_APP_PROTOCOL, process.execPath, [devEntrypoint]) : app.setAsDefaultProtocolClient(NIANCODE_APP_PROTOCOL); if (!registered) { logger.warn(`Failed to register ${NIANCODE_APP_PROTOCOL}:// protocol handler`); } } function createMainWindow(): BrowserWindow { const win = createWindow(); win.once('ready-to-show', () => { if (mainWindow !== win) { return; } const action = consumeMainWindowReady(mainWindowFocusState); if (action === 'focus') { focusWindow(win); return; } win.show(); }); win.on('close', (event) => { if (!isQuitting() && !isE2EMode) { event.preventDefault(); win.hide(); } }); win.on('closed', () => { if (mainWindow === win) { mainWindow = null; } }); mainWindow = win; return win; } /** * Initialize the application */ async function initialize(): Promise { // Initialize logger first logger.init(); logger.info('=== Makelore Application Starting ==='); logger.debug( `Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}` ); if (!isE2EMode) { registerMakeloreProtocolClient(); } if (launchDeepLinkUrl) { handleAppDeepLinkActivation(launchDeepLinkUrl); } if (!isE2EMode) { // Warm up network optimization (non-blocking) void warmupNetworkOptimization(); // Initialize Telemetry early await initTelemetry(); // Apply persisted proxy settings before creating windows or network requests. await applyProxySettings(); const meowaCredential = await initializeMeowaGameAssetsCredential(); if (meowaCredential.source !== 'none') { logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`); } await syncLaunchAtStartupSettingFromStore(); } else { logger.info('Running in E2E mode: startup side effects minimized'); } opencodeProjectStore = createProjectStore(await createElectronProjectStorage()); if (!isE2EMode) { projectProgressSync = createProjectProgressSync(opencodeProjectStore); void projectProgressSync.start(); } worksCloudDeployment = createWorksCloudDeployment(opencodeProjectStore); if (!isE2EMode) void worksCloudDeployment.start(); const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: app.isPackaged, configuredMode: process.env.NIANCODE_IMAGE_WORKSPACE_MODE, isDevelopmentServer: Boolean(process.env.VITE_DEV_SERVER_URL), }); const imageWorkspace = localImageWorkspaceEnabled ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) : new WorksSquareDesignWorkspace(); if (localImageWorkspaceEnabled) { logger.info('AI painting workspace is using local development storage'); } else { logger.info('AI painting workspace is using the Works Square cloud contract'); } // Set application menu createMenu(); // Create the main window const window = createMainWindow(); // Create system tray if (!isE2EMode) { createTray(window); } // Register IPC handlers registerIpcHandlers(undefined, opencodeManager, undefined, window); hostApiServer = startHostApiServer({ opencodeManager, opencodeProjectStore, eventBus: hostEventBus, mainWindow: window, worksCloudDeployment: worksCloudDeployment ?? undefined, imageWorkspace, }); // Register update handlers registerUpdateHandlers(appUpdater, window); // Note: Auto-check for updates is driven by the renderer (update store init) // so it respects the user's "Auto-check for updates" setting. opencodeManager.on('status', (status) => { logger.info( `opencode runtime status: state=${status.state}, port=${status.port}, pid=${status.pid ?? 'n/a'}, url=${status.url ?? 'n/a'}${status.error ? `, error=${status.error}` : ''}`, ); hostEventBus.emit('opencode:status', status); }); opencodeManager.on('stderr', (message) => { const trimmed = message.trim(); if (trimmed) { logger.warn(`[opencode stderr] ${trimmed}`); } hostEventBus.emit('opencode:stderr', { message }); }); browserOAuthManager.on('oauth:start', (payload) => { hostEventBus.emit('oauth:start', payload); }); browserOAuthManager.on('oauth:code', (payload) => { hostEventBus.emit('oauth:code', payload); }); browserOAuthManager.on('oauth:success', (payload) => { hostEventBus.emit('oauth:success', { ...payload, success: true }); }); browserOAuthManager.on('oauth:error', (error) => { hostEventBus.emit('oauth:error', error); }); } if (gotTheLock) { const requestQuitOnSignal = createSignalQuitHandler({ logInfo: (message) => logger.info(message), requestQuit: () => app.quit(), }); process.on('exit', () => { releaseProcessInstanceFileLock(); }); process.once('SIGINT', () => requestQuitOnSignal('SIGINT')); process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM')); app.on('will-quit', () => { releaseProcessInstanceFileLock(); }); if (process.platform === 'win32') { app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID); } const opencodePaths = resolveOpencodeRuntimePaths({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), platform: process.platform, arch: process.arch, }); const pythonRuntime = resolvePythonRuntime({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), platform: process.platform, arch: process.arch, configuredPath: process.env.NIANCODE_PYTHON_PATH, }); const bundledSuperpowersDir = resolveBundledSuperpowersDir({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), }); const bundledCourseSkillsDir = resolveBundledCourseSkillsDir({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), }); opencodeManager = new OpencodeManager({ port: 4096, binPath: opencodePaths.binPath, userDataDir: app.getPath('userData'), bundledSuperpowersDir, bundledCourseSkillsDir, pythonRuntime, runtimeConfigProvider: async () => { const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({ mcpServers: { [PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(), }, }); return { ...runtime, config: runtime.config as unknown as Record, env: { ...runtime.env, NIANCODE_HOST_API_BASE_URL: `http://127.0.0.1:${getPort('NIANCODE_HOST_API')}`, NIANCODE_HOST_API_TOKEN: getHostApiToken(), }, }; }, }); hostEventBus = new HostEventBus(); // When a second instance is launched, focus the existing window instead. app.on('second-instance', (_event, commandLine) => { logger.info('Second Makelore instance detected; redirecting to the existing window'); if ( app.isPackaged && process.platform === 'darwin' && executableSnapshotChanged(executableSnapshotAtLaunch, snapshotExecutable(process.execPath)) ) { logger.info('Detected that the installed app bundle changed while this instance was running; relaunching into the updated app'); setQuitting(); app.relaunch({ execPath: process.execPath }); app.quit(); return; } const deepLinkUrl = findNianCodeDeepLinkUrl(commandLine); if (deepLinkUrl && handleAppDeepLinkActivation(deepLinkUrl)) { return; } requestMainWindowFocus('second instance'); }); app.on('open-url', (event, url) => { event.preventDefault(); if (!handleAppDeepLinkActivation(url)) { logger.warn('Ignored unsupported Makelore app link'); } }); // Application lifecycle app.whenReady().then(() => { void initialize().catch((error) => { logger.error('Application initialization failed:', error); }); // Register activate handler AFTER app is ready to prevent // "Cannot create BrowserWindow before app is ready" on macOS. app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createMainWindow(); } else { focusMainWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin' || isE2EMode) { app.quit(); } }); app.on('before-quit', (event) => { setQuitting(); const action = requestQuitLifecycleAction(quitLifecycleState); if (action === 'allow-quit') { return; } event.preventDefault(); if (action === 'cleanup-in-progress') { logger.debug('Quit requested while cleanup already in progress; waiting for shutdown task to finish'); return; } hostEventBus.closeAll(); hostApiServer?.close(); projectProgressSync?.stop(); worksCloudDeployment?.stop(); const stopOpencodePromise = opencodeManager.stop().catch((err) => { logger.warn('opencodeManager.stop() error during quit:', err); }); const stopPromise = Promise.allSettled([stopOpencodePromise]); const timeoutPromise = new Promise<'timeout'>((resolve) => { setTimeout(() => resolve('timeout'), 5000); }); void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => { if (result === 'timeout') { logger.warn('opencode shutdown timed out during app quit; proceeding with forced quit'); } markQuitCleanupCompleted(quitLifecycleState); app.quit(); }); }); // Best-effort runtime cleanup on unexpected crashes. // These handlers attempt to terminate the opencode child process within a // short timeout before force-exiting, preventing orphaned processes. const emergencyRuntimeCleanup = (reason: string, error: unknown): void => { logger.error(`${reason}:`, error); projectProgressSync?.stop(); worksCloudDeployment?.stop(); try { void opencodeManager?.stop().catch(() => { /* ignore */ }); } catch { // ignore — stop() may not be callable if state is corrupted } // Give runtime stop a brief window, then force-exit. setTimeout(() => { process.exit(1); }, 3000).unref(); }; process.on('uncaughtException', (error) => { emergencyRuntimeCleanup('Uncaught exception in main process', error); }); process.on('unhandledRejection', (reason) => { emergencyRuntimeCleanup('Unhandled promise rejection in main process', reason); }); } // Export for testing export { mainWindow, opencodeManager, opencodeProjectStore };