// 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, ipcMain, Menu, session } from 'electron'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { CHANNELS } from './ipc-types'; import { register as registerAudioLoopback } from './modules/audio-loopback'; import { register as registerAppHostname } from './modules/app-hostname'; 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 registerWindowContentProtection } from './modules/window-content-protection'; 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'; // Pin userData FIRST — before any other Electron call that might cache a // productName-derived path. The 0.17.0 release saw users get logged out // after upgrading from 0.16.x: the most likely culprit was an internal // path resolution kicking off the moment `setName('Netralax')` ran, so // 0.17.1 swaps the order so the explicit override wins regardless of // what setName triggers internally. The literal 'ChatApp' here is the // pre-rename product folder — installed users' SQLite, secrets, sounds, // IndexedDB all live there and we never want to leave them stranded by // a future rebrand. app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp')); // App branding. productName in package.json drives the packaged exe name // (Netralax.exe) and electron-builder installer title. setName + the // AppUserModelId cover the live process: window title fallback, Windows // taskbar grouping, notification source attribution. app.setName('Netralax'); if (process.platform === 'win32') { app.setAppUserModelId('cloud.netralax.desktop'); } // 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'); } // Startup diagnostics — the 0.17.0 logout regression was hard to debug // because we had no record of the actual resolved paths. With this log // any future user can paste their main-process output and we can tell // at a glance whether userData ended up where we intended. console.log( '[main] resolved paths', JSON.stringify({ appName: app.getName(), appData: app.getPath('appData'), userData: app.getPath('userData'), isPackaged: app.isPackaged, platform: process.platform, }), ); 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 ? 'Netralax' : 'Netralax (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) { // Auto-open DevTools in dev — the menu bar is stripped (Discord-style) // so F12 / Ctrl+Shift+I have no chord; opening detached gives a // separate inspector window for easy debugging. win.webContents.openDevTools({ mode: 'detach' }); // Forward renderer console messages to the main-process stdout so // errors during local dev are visible in the terminal too (helps when // the inspector isn't focused). win.webContents.on('console-message', (_event, level, message, line, sourceId) => { const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info'; console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')'); }); 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(); registerAppHostname(); // Window-dependent registrars — only call once mainWindow exists so // event emitters have somewhere to send. registerShortcuts(mainWindow); registerTray(mainWindow); registerUpdater(mainWindow); registerWindowFullscreen(mainWindow); registerWindowContentProtection(mainWindow); registerAudioLoopback(mainWindow); }); // Wipe-on-close: when the user enables it in Settings, the renderer is given // a chance to clear all sensitive caches before the app process exits. If // the renderer doesn't ack within 2 seconds we force-quit anyway — better // to lose the wipe than to hang the app shutdown. // // Note: there's a separate `before-quit` listener in modules/tray.ts that // tears down the Tray instance. Electron fires both; the tray listener is // synchronous and doesn't touch event.preventDefault, so it doesn't fight // our deferred-quit dance here. The `wipeRequested` flag guards re-entry // when our own `app.quit()` below fires `before-quit` a second time. let wipeRequested = false; app.on('before-quit', (event) => { if (wipeRequested) return; if (!mainWindow || mainWindow.isDestroyed()) return; wipeRequested = true; event.preventDefault(); mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT); const done = new Promise((resolve) => { ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve()); }); void Promise.race([done, new Promise((r) => setTimeout(r, 2000))]).finally(() => { app.quit(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (!mainWindow) { void createWindow().then((w) => { mainWindow = w; }); } }); }