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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user