import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions, type Tray } from 'electron'; import { join } from 'node:path'; import { startDaemon, type DaemonHandle } from './daemon'; import { seedSessionCookie } from './auth'; import { loadWindowState, saveWindowState } from './window-state'; import { createTray } from './tray'; import { initUpdater } from './updater'; const PARTITION = 'persist:arboretum'; const PORT = 7317; let daemon: DaemonHandle | null = null; let win: BrowserWindow | null = null; let tray: Tray | null = null; let isQuitting = false; let shuttingDown = false; // Instance unique : deux instances = deux daemons/ports en conflit. if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on('second-instance', showWindow); app.whenReady().then(bootstrap).catch((err: unknown) => { console.error('[arboretum-desktop] bootstrap failed:', err); app.quit(); }); } async function bootstrap(): Promise { const dataDir = join(app.getPath('userData'), 'daemon'); daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) }); await seedSessionCookie(PARTITION, daemon.url, daemon.token); createWindow(daemon.url); tray = createTray({ show: showWindow, quit: quitApp }); initUpdater(); } function showWindow(): void { if (!win) return; if (win.isMinimized()) win.restore(); win.show(); win.focus(); } function quitApp(): void { isQuitting = true; app.quit(); } function createWindow(url: string): void { const state = loadWindowState(); const opts: BrowserWindowConstructorOptions = { width: state.width, height: state.height, backgroundColor: '#09090b', webPreferences: { partition: PARTITION, contextIsolation: true, sandbox: true, nodeIntegration: false, preload: join(__dirname, 'preload.js'), }, }; if (state.x !== undefined) opts.x = state.x; if (state.y !== undefined) opts.y = state.y; win = new BrowserWindow(opts); if (state.maximized) win.maximize(); // Liens externes -> navigateur système ; toute navigation hors origine locale est déviée. win.webContents.setWindowOpenHandler(({ url: u }) => { void shell.openExternal(u); return { action: 'deny' }; }); win.webContents.on('will-navigate', (e, u) => { if (!u.startsWith(url)) { e.preventDefault(); void shell.openExternal(u); } }); // Permissions : n'autoriser que les notifications (cohérent avec la Permissions-Policy serveur). session.fromPartition(PARTITION).setPermissionRequestHandler((_wc, permission, cb) => cb(permission === 'notifications')); const persist = (): void => { if (!win) return; const b = win.getBounds(); saveWindowState({ width: b.width, height: b.height, x: b.x, y: b.y, maximized: win.isMaximized() }); }; // Fermer la fenêtre = réduire dans le tray (l'app continue en arrière-plan) ; quitter réellement // se fait via le menu du tray ou le menu applicatif. win.on('close', (e) => { persist(); if (!isQuitting) { e.preventDefault(); win?.hide(); } }); win.on('closed', () => { win = null; }); void win.loadURL(`${url}/`); } app.on('before-quit', (e) => { // Ne pas quitter avant l'arrêt propre du daemon enfant. isQuitting = true; if (daemon && !shuttingDown) { e.preventDefault(); void shutdown(); } }); async function shutdown(): Promise { if (shuttingDown) return; shuttingDown = true; try { await daemon?.stop(); } finally { daemon = null; tray?.destroy(); tray = null; app.quit(); } }