825160ee46
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>
233 lines
8.9 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
});
|
|
}
|