// Electron entry point. Creates the single BrowserWindow, wires all IPC // module registrars, and keeps a single running instance (second launch // focuses the existing window instead of opening a new one). // // Loads the renderer from the Vite dev server in dev (port 1420, matches // the old Tauri devUrl so nothing in the renderer code needs to change) // and from the built dist in packaged mode. import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { register as registerAudioLoopback } from './modules/audio-loopback'; import { register as registerAutostart } from './modules/autostart'; import { register as registerFsScoped } from './modules/fs-scoped'; import { register as registerNotifications } from './modules/notifications'; import { register as registerScreenAudio } from './modules/screen-audio'; import { consumePendingShareSourceId, register as registerScreenSources, } from './modules/screen-sources'; import { register as registerSecureStore } from './modules/secure-store'; import { register as registerShortcuts } from './modules/shortcuts'; import { register as registerSql } from './modules/sql'; import { register as registerTray } from './modules/tray'; import { register as registerUpdater } from './modules/updater'; import { register as registerWindowFullscreen } from './modules/window-fullscreen'; import { attach as attachWindowState, loadState } from './window-state'; // __dirname in an ESM main-process bundle resolves to the out/main dir // after electron-vite builds. Use a safe helper that works in both CJS // (electron-vite main target defaults to CJS) and ESM contexts. const __filenameSafe = typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url); const __dirnameSafe = path.dirname(__filenameSafe); const DEV_URL = 'http://localhost:1420'; const WINDOW_STATE_FILE = 'window-state.json'; // Run dev side-by-side with the installed packaged build by isolating the // renderer profile / secret-store / SQLite / IndexedDB / localStorage in // a separate userData dir. Without this both share `%APPDATA%\ChatApp`, // the single-instance lock fires, and `pnpm dev` exits immediately while // the installed prod app holds the lock. Must run BEFORE the lock check // below + before any other module reads `app.getPath('userData')`. if (!app.isPackaged) { app.setPath('userData', app.getPath('userData') + '-Dev'); } let mainWindow: BrowserWindow | null = null; function resolvePreloadPath(): string { // electron-vite names the preload bundle after the entry filename, so // electron/preload.ts → out/preload/preload.mjs. ESM preload works in // modern Electron when sandbox: false (which we have — preload needs // Node APIs for libsodium init etc.). The previous `index.cjs` lookup // was a stale leftover that silently never loaded — every IPC call // (secret store, autostart, native notifications, screen sources, // pending-share-source) returned undefined and any .x access threw. return path.join(__dirnameSafe, '..', 'preload', 'preload.mjs'); } function resolveRendererIndex(): string { return path.join(__dirnameSafe, '..', 'renderer', 'index.html'); } function resolveIconPath(): string { const packaged = path.join(process.resourcesPath || '', 'icon.ico'); const dev = path.join(app.getAppPath(), 'resources', 'icon.ico'); return app.isPackaged ? packaged : dev; } async function createWindow(): Promise { const state = await loadState(WINDOW_STATE_FILE); const win = new BrowserWindow({ title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)', width: state.width, height: state.height, ...(state.x !== undefined ? { x: state.x } : {}), ...(state.y !== undefined ? { y: state.y } : {}), minWidth: 800, minHeight: 600, resizable: true, icon: resolveIconPath(), show: false, // Discord-style: no menu bar at all (no File/Edit/View/Window/Help). // Belt: autoHideMenuBar hides it visually; suspenders: the global // Menu.setApplicationMenu(null) below removes it entirely so Alt // can't reveal it either. autoHideMenuBar: true, webPreferences: { contextIsolation: true, nodeIntegration: false, // sandbox: false — preload needs Node APIs (libsodium init etc.) sandbox: false, preload: resolvePreloadPath(), // Strip DevTools from release builds. Disables openDevTools(), // F12, Ctrl+Shift+I, and the right-click Inspect entry for end // users. Packaged = release; unpackaged = `electron-vite dev`, // where we still want the inspector for local debugging. devTools: !app.isPackaged, }, }); if (state.maximized) win.maximize(); win.once('ready-to-show', () => win.show()); attachWindowState(win, WINDOW_STATE_FILE); if (!app.isPackaged) { await win.loadURL(DEV_URL); } else { await win.loadFile(resolveRendererIndex()); } win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); return win; } function configureDisplayMediaHandler(): void { // Our custom picker runs in the renderer; when LiveKit (or anything // else) calls navigator.mediaDevices.getDisplayMedia with a specific // chromeMediaSourceId constraint, Chromium defers to the handler we // install here. // // Two paths: // 1. Discord-style picker case — the renderer set a pending source id // via SCREEN_SET_PENDING_SOURCE before calling getDisplayMedia. We // resolve that id back to a desktopCapturer Source and pass it as // the video grant. This is what makes the user's tile choice // actually take effect. // 2. Fallback — no pending id (e.g. a stray getDisplayMedia from // another code path). Grant whatever the renderer's constraint // selected by passing the frame back, matching the legacy v0.11.x // behavior so we don't break anything off the picker code path. // // Audio: 'loopback' captures all system audio. We tried // 'loopbackWithMute' (which excludes this app's own audio output from // the capture) but it ALSO mutes the renderer's audio playback // locally — so the user couldn't hear remote peers during a share // and the stream had no audio either when the only thing playing was // the muted call audio. There is no clean Electron equivalent of // Tauri's EXCLUDE_TARGET_PROCESS_TREE that affects only the capture // and not local playback. Trade-off chosen here: keep local audio // working ('loopback'), let the user opt into the JS-level auto-duck // (`screenShareSettings.duckRemoteAudioWhileSharing`) when they // actually need the echo prevention. Default: off. // https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts session.defaultSession.setDisplayMediaRequestHandler( (request, callback) => { const pendingId = consumePendingShareSourceId(); if (pendingId) { void desktopCapturer .getSources({ types: ['screen', 'window'] }) .then((sources) => { const source = sources.find((s) => s.id === pendingId); if (source) { callback({ video: source, audio: 'loopback' }); } else { // Source vanished between picker confirm and grant (window // closed, monitor unplugged). Fall back to the frame grant // so getDisplayMedia doesn't hang the renderer. callback({ video: request.frame as unknown as Electron.WebFrameMain, audio: 'loopback', }); } }) .catch(() => { callback({ video: request.frame as unknown as Electron.WebFrameMain, audio: 'loopback', }); }); return; } callback({ video: request.frame as unknown as Electron.WebFrameMain, audio: 'loopback', }); }, { useSystemPicker: false }, ); } // Single-instance lock — second launch focuses existing window instead // of spawning a duplicate process. const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } else { app.on('second-instance', () => { if (!mainWindow) return; if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); }); void app.whenReady().then(async () => { // Strip the default application menu globally — we don't ship any // File/Edit/View/Window/Help entries (Discord-style chrome). Must run // BEFORE the first BrowserWindow is created; setting it later causes // the menu to flash for a frame on Windows. Menu.setApplicationMenu(null); configureDisplayMediaHandler(); mainWindow = await createWindow(); // Stateless registrars first. registerScreenSources(); registerScreenAudio(); registerNotifications(); registerSecureStore(); registerFsScoped(); registerSql(); registerAutostart(); // Window-dependent registrars — only call once mainWindow exists so // event emitters have somewhere to send. registerShortcuts(mainWindow); registerTray(mainWindow); registerUpdater(mainWindow); registerWindowFullscreen(mainWindow); registerAudioLoopback(mainWindow); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (!mainWindow) { void createWindow().then((w) => { mainWindow = w; }); } }); }