Files
ChatApp/apps/desktop/electron/main.ts
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

233 lines
8.9 KiB
TypeScript

// 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';
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<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: 'ChatApp',
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;
});
}
});
}