import { app, BrowserWindow, ipcMain, dialog, type BrowserWindowConstructorOptions, } from 'electron'; import { execSync } from 'node:child_process'; import { fork, type ChildProcess } from 'node:child_process'; import { createServer } from 'node:net'; import { join, extname } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'; import { buildAppMenu } from './app-menu'; import { PORT_FILE_DIR_NAME, PORT_FILE_NAME, VITE_DEV_PORT, WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, TITLEBAR_OVERLAY_HEIGHT, MACOS_TRAFFIC_LIGHT_POSITION, MACOS_TRAFFIC_LIGHT_PAD, WIN_CONTROLS_PAD, LINUX_CONTROLS_PAD, NITRO_HOST, NITRO_FALLBACK_TIMEOUT_WIN, NITRO_FALLBACK_TIMEOUT_DEFAULT, } from './constants'; import { setupAutoUpdater, broadcastUpdaterState, setUpdaterState, clearUpdateTimer, setAutoUpdateEnabled, } from './auto-updater'; import { initLogger, log } from './logger'; import { setupIPC } from './ipc-handlers'; import { buildUnsavedChangesDialogOptions, mapUnsavedChangesResponse, } from './unsaved-changes-dialog'; import { setupGitIPC } from './git/ipc-handlers'; let mainWindow: BrowserWindow | null = null; let nitroProcess: ChildProcess | null = null; let serverPort = 0; let pendingFilePath: string | null = null; const isDev = !app.isPackaged; // Settings stored in platform-standard app data dir (Electron-managed): // macOS: ~/Library/Application Support/OpenPencil/ // Windows: %APPDATA%\OpenPencil\ // Linux: ~/.config/OpenPencil/ const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json'); const PREFS_PATH = join(app.getPath('userData'), 'preferences.json'); // --------------------------------------------------------------------------- // Renderer preferences (replaces localStorage which is origin-scoped) // --------------------------------------------------------------------------- let prefsCache: Record = {}; let prefsDirty = false; let prefsWriteTimer: ReturnType | null = null; async function loadPrefs(): Promise { try { const raw = await readFile(PREFS_PATH, 'utf-8'); prefsCache = JSON.parse(raw); } catch { prefsCache = {}; } } function schedulePrefsWrite(): void { if (prefsWriteTimer) return; prefsDirty = true; prefsWriteTimer = setTimeout(async () => { prefsWriteTimer = null; if (!prefsDirty) return; prefsDirty = false; try { await mkdir(app.getPath('userData'), { recursive: true }); await writeFile(PREFS_PATH, JSON.stringify(prefsCache, null, 2), 'utf-8'); } catch (err) { log.error(`[prefs] Failed to write preferences: ${err}`); } }, 500); } // --------------------------------------------------------------------------- // Fix PATH for GUI apps (shell PATH not inherited) // --------------------------------------------------------------------------- function fixPath(): void { if (process.platform === 'win32') { // Windows GUI apps inherit PATH from the system, but common tool install // dirs (npm global, scoop, cargo, etc.) may be missing in packaged apps. const home = homedir(); const extraDirs = [ join(home, 'AppData', 'Roaming', 'npm'), // npm global join(home, 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'bin'), // VS Code CLI join(home, '.cargo', 'bin'), // Rust/cargo join(home, 'scoop', 'shims'), // scoop join(home, '.bun', 'bin'), // bun ]; const current = process.env.PATH || ''; const existing = new Set(current.split(';').map((p) => p.toLowerCase())); const additions = extraDirs.filter((d) => !existing.has(d.toLowerCase())); if (additions.length > 0) { process.env.PATH = [...additions, current].join(';'); } return; } if (process.platform !== 'darwin' && process.platform !== 'linux') return; try { const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, { encoding: 'utf-8', timeout: 5000, }).trim(); if (shellPath) { const current = process.env.PATH || ''; process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])] .filter(Boolean) .join(':'); } } catch { // Packaged app may not have a login shell — add common tool dirs as fallback const home = homedir(); const fallbackDirs = [ join(home, '.local', 'bin'), join(home, '.cargo', 'bin'), join(home, '.bun', 'bin'), '/usr/local/bin', '/opt/homebrew/bin', ]; const current = process.env.PATH || ''; const existing = new Set(current.split(':')); const additions = fallbackDirs.filter((d) => !existing.has(d)); if (additions.length > 0) { process.env.PATH = [...additions, current].join(':'); } } } // --------------------------------------------------------------------------- // App settings // --------------------------------------------------------------------------- interface AppSettings { autoUpdate?: boolean; } async function readAppSettings(): Promise { try { const raw = await readFile(SETTINGS_PATH, 'utf-8'); return JSON.parse(raw); } catch { return {}; } } async function writeAppSettings(patch: Partial): Promise { const current = await readAppSettings(); const merged = { ...current, ...patch }; await mkdir(app.getPath('userData'), { recursive: true }); await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8'); } // --------------------------------------------------------------------------- // Port file for MCP sync discovery (~/.openpencil/.port) // --------------------------------------------------------------------------- const PORT_FILE_DIR = join(homedir(), PORT_FILE_DIR_NAME); const PORT_FILE_PATH = join(PORT_FILE_DIR, PORT_FILE_NAME); async function writePortFile(port: number): Promise { try { await mkdir(PORT_FILE_DIR, { recursive: true }); await writeFile( PORT_FILE_PATH, JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }), 'utf-8', ); } catch (err) { log.error(`[port-file] Failed to write port file: ${err}`); } } async function cleanupPortFile(): Promise { try { await unlink(PORT_FILE_PATH); } catch { // Ignore if already removed } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getFreePorts(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, NITRO_HOST, () => { const addr = server.address(); if (addr && typeof addr === 'object') { const { port } = addr; server.close(() => resolve(port)); } else { reject(new Error('Failed to get free port')); } }); server.on('error', reject); }); } function getServerEntry(): string { if (isDev) { // In dev, the Nitro output lives at out/web/server/index.mjs return join(app.getAppPath(), 'out', 'web', 'server', 'index.mjs'); } // In production, extraResources copies out/web into the resources folder return join(process.resourcesPath, 'server', 'index.mjs'); } // --------------------------------------------------------------------------- // Nitro server // --------------------------------------------------------------------------- async function startNitroServer(): Promise { const port = await getFreePorts(); const entry = getServerEntry(); return new Promise((resolve, reject) => { const child = fork(entry, [], { env: { ...process.env, HOST: NITRO_HOST, PORT: String(port), NITRO_HOST: NITRO_HOST, NITRO_PORT: String(port), ELECTRON_RESOURCES_PATH: process.resourcesPath, }, stdio: 'pipe', }); nitroProcess = child; child.stdout?.on('data', (data: Buffer) => { const msg = data.toString(); log.info(`[nitro] ${msg.trimEnd()}`); // Resolve once Nitro reports it's listening if (msg.includes('Listening') || msg.includes('ready')) { resolve(port); } }); child.stderr?.on('data', (data: Buffer) => { log.error(`[nitro:err] ${data.toString().trimEnd()}`); }); child.on('error', reject); child.on('exit', (code) => { if (code !== 0 && code !== null) { log.error(`Nitro exited with code ${code}`); } nitroProcess = null; // Auto-restart Nitro server if it crashes while app is running if (code !== 0 && code !== null && mainWindow && !mainWindow.isDestroyed()) { log.info('[nitro] Restarting server after crash...'); startNitroServer() .then((newPort) => { serverPort = newPort; writePortFile(newPort); log.info(`[nitro] Restarted on port ${newPort}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(`http://${NITRO_HOST}:${newPort}/editor`); } }) .catch((err) => { log.error(`[nitro] Failed to restart: ${err}`); }); } }); // Fallback: if no stdout "ready" message comes, wait then resolve anyway. // Use longer timeout on Windows (slower process creation). const fallbackMs = process.platform === 'win32' ? NITRO_FALLBACK_TIMEOUT_WIN : NITRO_FALLBACK_TIMEOUT_DEFAULT; setTimeout(() => resolve(port), fallbackMs); }); } // --------------------------------------------------------------------------- // Linux window-controls side detection // --------------------------------------------------------------------------- /** Cached result for Linux controls side detection. */ let cachedLinuxControlsSide: 'left' | 'right' | null = null; /** * Detect whether Linux DE places window controls on the left or right. * Uses gsettings (GNOME/Cinnamon/MATE) as primary check, checks XDG_CURRENT_DESKTOP * for known right-side DEs, then defaults to right. Result is cached. */ function getLinuxControlsSide(): 'left' | 'right' { if (cachedLinuxControlsSide) return cachedLinuxControlsSide; let result: 'left' | 'right' = 'right'; // Try gsettings (works for GNOME, Cinnamon, MATE, Budgie) try { const layout = execSync('gsettings get org.gnome.desktop.wm.preferences button-layout', { encoding: 'utf-8', timeout: 3000, }) .trim() .replace(/'/g, ''); const colonIndex = layout.indexOf(':'); if (colonIndex >= 0) { const beforeColon = layout.slice(0, colonIndex); if ( beforeColon.includes('close') || beforeColon.includes('minimize') || beforeColon.includes('maximize') ) { result = 'left'; } } } catch { // gsettings not available — check desktop environment const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase(); // KDE, XFCE, LXQt default to right. elementary OS defaults to left. if (desktop.includes('pantheon')) { result = 'left'; } } cachedLinuxControlsSide = result; return result; } // --------------------------------------------------------------------------- // Window // --------------------------------------------------------------------------- function createWindow(): void { const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'; const windowOptions: BrowserWindowConstructorOptions = { width: WINDOW_WIDTH, height: WINDOW_HEIGHT, minWidth: WINDOW_MIN_WIDTH, minHeight: WINDOW_MIN_HEIGHT, title: 'OpenPencil', titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', ...(isWinOrLinux ? { titleBarOverlay: { // Windows supports transparent overlay; Linux uses solid color (updated via theme:set IPC) color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#1a1a1a', symbolColor: '#d4d4d8', height: TITLEBAR_OVERLAY_HEIGHT, }, } : {}), webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, // Persist localStorage/cookies in a fixed partition so data survives // across random Nitro server port changes (origin-independent storage). partition: 'persist:openpencil', // Disable DevTools entirely in packaged builds. This blocks the API, // any role-based menu item, and keyboard shortcuts at the lowest level. devTools: isDev, }, }; if (process.platform === 'darwin') { windowOptions.trafficLightPosition = MACOS_TRAFFIC_LIGHT_POSITION; } // Start hidden to avoid visual flash before CSS injection windowOptions.show = false; mainWindow = new BrowserWindow(windowOptions); // Hide native menu bar on Windows/Linux (shortcuts still work via Alt key) if (isWinOrLinux) { mainWindow.setAutoHideMenuBar(true); mainWindow.setMenuBarVisibility(false); } // Block reload / DevTools keyboard shortcuts in packaged builds. The View // menu hides the items in prod (see app-menu.ts), but Chromium still ships // built-in handlers for Cmd/Ctrl+R, Cmd/Ctrl+Shift+R, F5 and the DevTools // chord — swallow them at the input layer. if (!isDev) { mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; const cmdOrCtrl = process.platform === 'darwin' ? input.meta : input.control; const key = input.key.toLowerCase(); const isReload = (cmdOrCtrl && key === 'r') || key === 'f5'; const isDevTools = key === 'f12' || (cmdOrCtrl && input.shift && (key === 'i' || key === 'j' || key === 'c')) || (process.platform === 'darwin' && input.meta && input.alt && key === 'i'); if (isReload || isDevTools) { event.preventDefault(); } }); } const url = isDev ? `http://localhost:${VITE_DEV_PORT}/editor` : `http://${NITRO_HOST}:${serverPort}/editor`; // Inject traffic-light padding CSS then show window (no flash) mainWindow.webContents.on('did-finish-load', async () => { if (!mainWindow) return; if (process.platform === 'darwin') { await mainWindow.webContents.insertCSS( `.electron-traffic-light-pad { margin-left: ${MACOS_TRAFFIC_LIGHT_PAD}px; }` + '.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }', ); } if (process.platform === 'win32') { await mainWindow.webContents.insertCSS( `.electron-win-controls-pad { margin-right: ${WIN_CONTROLS_PAD}px; }`, ); } if (process.platform === 'linux') { const side = getLinuxControlsSide(); if (side === 'left') { await mainWindow.webContents.insertCSS( `.electron-traffic-light-pad { margin-left: ${LINUX_CONTROLS_PAD}px; }`, ); } else { await mainWindow.webContents.insertCSS( `.electron-win-controls-pad { margin-right: ${LINUX_CONTROLS_PAD}px; }`, ); } } mainWindow.show(); broadcastUpdaterState(); }); // Toggle fullscreen class to remove traffic-light padding in fullscreen if (process.platform === 'darwin') { mainWindow.on('enter-full-screen', () => { mainWindow?.webContents.executeJavaScript( 'document.body.classList.add("electron-fullscreen")', ); }); mainWindow.on('leave-full-screen', () => { mainWindow?.webContents.executeJavaScript( 'document.body.classList.remove("electron-fullscreen")', ); }); } mainWindow.loadURL(url); if (isDev) { mainWindow.webContents.openDevTools({ mode: 'detach' }); } // --------------------------------------------------------------------------- // Unsaved-changes close confirmation // --------------------------------------------------------------------------- let forceClose = false; mainWindow.on('close', (event) => { if (forceClose || !mainWindow || mainWindow.isDestroyed()) return; // preventDefault must be called synchronously — check dirty state after event.preventDefault(); const win = mainWindow; win.webContents .executeJavaScript( '({ dirty: window.__documentIsDirty === true,' + ' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' + ' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' + ' yes: typeof window.__i18nT === "function" ? window.__i18nT("common.yes") : "",' + ' no: typeof window.__i18nT === "function" ? window.__i18nT("common.no") : "",' + ' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })', ) .then( (result: { dirty: boolean; message: string; detail: string; yes: string; no: string; cancel: string; }) => { if (!result.dirty) { // Not dirty — allow close forceClose = true; win.close(); return; } // Show native save dialog with i18n strings return dialog .showMessageBox( win, buildUnsavedChangesDialogOptions({ yesLabel: result.yes || 'Yes', noLabel: result.no || 'No', cancelLabel: result.cancel || 'Cancel', message: result.message || 'Do you want to save changes before closing?', detail: result.detail || "Your changes will be lost if you don't save them.", }), ) .then(({ response }) => { const decision = mapUnsavedChangesResponse(response); if (decision === 'save') { // Save — tell renderer to save then confirm close win.webContents.send('menu:action', 'save-and-close'); } else if (decision === 'discard') { // No — force close without saving forceClose = true; win.close(); } // Cancel — do nothing, window stays open }); }, ) .catch(() => { // Page not loaded or crashed — allow close forceClose = true; win.close(); }); }); // Renderer confirms save completed → force close ipcMain.on('window:confirmClose', () => { forceClose = true; mainWindow?.close(); }); mainWindow.on('closed', () => { mainWindow = null; }); } // --------------------------------------------------------------------------- // IPC: native file dialogs & updater (extracted to ipc-handlers.ts) // --------------------------------------------------------------------------- function initIPC(): void { setupIPC({ getMainWindow: () => mainWindow, getPendingFilePath: () => pendingFilePath, clearPendingFilePath: () => { pendingFilePath = null; }, prefsCache, schedulePrefsWrite, writeAppSettings, }); setupGitIPC(); } // --------------------------------------------------------------------------- // File association: open .op files // --------------------------------------------------------------------------- /** Extract .op file path from command-line arguments. */ function getFilePathFromArgs(args: string[]): string | null { for (const arg of args) { // Skip flags and the Electron binary/script path if (arg.startsWith('-') || arg.startsWith('--')) continue; const ext = extname(arg).toLowerCase(); if (ext === '.op' || ext === '.pen') { return arg; } } return null; } /** Send a file path to the renderer for loading. */ function sendOpenFile(filePath: string): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('file:open', filePath); } else { pendingFilePath = filePath; } } // macOS: open-file fires when user double-clicks a .op file app.on('open-file', (event, filePath) => { event.preventDefault(); if (app.isReady()) { sendOpenFile(filePath); } else { pendingFilePath = filePath; } }); // Single instance lock (Windows/Linux: second instance passes file path as arg) const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', (_event, argv) => { const filePath = getFilePathFromArgs(argv); if (filePath) { sendOpenFile(filePath); } // Focus existing window if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); } // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- app.on('ready', async () => { await initLogger(app.getPath('userData')); fixPath(); await loadPrefs(); initIPC(); buildAppMenu(); if (!isDev) { try { serverPort = await startNitroServer(); log.info(`Nitro server started on port ${serverPort}`); await writePortFile(serverPort); } catch (err) { log.error(`Failed to start Nitro server: ${err}`); dialog.showErrorBox( 'OpenPencil', `Failed to start the application server.\n\n${err instanceof Error ? err.message : String(err)}\n\nThe application will now quit.`, ); app.quit(); return; } } else { // Dev mode: Vite dev server runs on port 3000 await writePortFile(VITE_DEV_PORT); } createWindow(); // Check for file to open: pending open-file event or CLI args (Windows/Linux). // The file path is stored in pendingFilePath and pulled by the renderer // via file:getPending IPC when the React app mounts (useElectronMenu hook). if (!pendingFilePath) { pendingFilePath = getFilePathFromArgs(process.argv); } if (!isDev) { const settings = await readAppSettings(); const autoUpdate = settings.autoUpdate !== false; setAutoUpdateEnabled(autoUpdate); if (autoUpdate) { setupAutoUpdater(); } else { setUpdaterState({ status: 'disabled' }); } } }); app.on('window-all-closed', () => { app.quit(); }); app.on('activate', () => { if (mainWindow === null) { createWindow(); } }); app.on('before-quit', () => { clearUpdateTimer(); killNitroProcess(); killMcpServer(); cleanupPortFile().catch(() => {}); }); /** Kill the detached MCP server child (spawned by Nitro via mcp-server-manager). */ function killMcpServer(): void { // PID file path matches apps/web/server/utils/mcp-server-manager.ts const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid'); const portFile = join(tmpdir(), 'openpencil-mcp-server.port'); try { if (!existsSync(pidFile)) return; const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10); if (!Number.isFinite(pid)) return; try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ } } catch { /* ignore */ } try { unlinkSync(pidFile); } catch { /* ignore */ } try { unlinkSync(portFile); } catch { /* ignore */ } } /** Platform-aware Nitro process termination. */ function killNitroProcess(): void { if (!nitroProcess) return; const pid = nitroProcess.pid; if (process.platform === 'win32') { // SIGTERM is unreliable on Windows; use taskkill for proper tree-kill try { if (pid) { execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); } } catch { /* process may have already exited */ } } else { // SIGTERM may not be processed before the main process exits, // leaving orphan Nitro processes. Kill the entire process group // with SIGKILL for reliable cleanup. try { if (pid) process.kill(-pid, 'SIGKILL'); } catch { /* process may have already exited */ } try { nitroProcess.kill('SIGKILL'); } catch { /* ignore */ } } nitroProcess = null; } // Ensure child process cleanup on unexpected termination (Linux/macOS signals) for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { process.on(signal, () => { killNitroProcess(); cleanupPortFile().finally(() => process.exit(0)); }); }