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,213 @@
|
||||
// Native WASAPI process-loopback bridge. Wraps the
|
||||
// @chatapp/audio-loopback-native napi-rs addon and forwards PCM chunks
|
||||
// to the renderer over IPC.
|
||||
//
|
||||
// Why the addon over Chromium's built-in 'loopback' source: the OS
|
||||
// process-loopback API supports EXCLUDE_TARGET_PROCESS_TREE, which lets
|
||||
// us capture every render session *except* our own PID tree. That keeps
|
||||
// LiveKit's call playback out of the outgoing share so peers don't hear
|
||||
// themselves echoed back. Chromium's 'loopback' has no such filter.
|
||||
//
|
||||
// Windows-only. The addon's start_capture call rejects on macOS/Linux
|
||||
// with a clear error string and the renderer falls through to the
|
||||
// existing Chromium getUserMedia path (lib/screenAudio.ts).
|
||||
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
CHANNELS,
|
||||
type AudioLoopbackChunk,
|
||||
type AudioLoopbackStartResult,
|
||||
} from '../ipc-types';
|
||||
|
||||
interface NativeAudioLoopback {
|
||||
startCapture: (callback: (samples: Float32Array) => void) => number;
|
||||
/** INCLUDE_TARGET_PROCESS_TREE variant — capture only `pid`'s tree.
|
||||
* Used for window-shares so we get just the picked app's audio. */
|
||||
startCaptureForPid?: (
|
||||
pid: number,
|
||||
callback: (samples: Float32Array) => void,
|
||||
) => number;
|
||||
/** Look up the owning process id of a top-level window handle. */
|
||||
resolveWindowPid?: (hwnd: number) => number;
|
||||
stopCapture: (captureId: number) => void;
|
||||
}
|
||||
|
||||
// __dirname in an ESM main bundle resolves to out/main after
|
||||
// electron-vite builds. We need a CJS-style require to load the native
|
||||
// addon — `import` would trigger ESM resolution which doesn't handle
|
||||
// .node files cleanly across electron-vite's transform.
|
||||
const __filenameSafe =
|
||||
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
|
||||
const __dirnameSafe = path.dirname(__filenameSafe);
|
||||
const requireCjs = createRequire(__filenameSafe);
|
||||
|
||||
let cachedAddon: NativeAudioLoopback | null | undefined;
|
||||
|
||||
function resolveAddon(): NativeAudioLoopback | null {
|
||||
if (cachedAddon !== undefined) return cachedAddon;
|
||||
|
||||
// Production: electron-builder copies the .node into
|
||||
// resources/native/audio-loopback.node (see extraResources in
|
||||
// package.json).
|
||||
// Dev: napi build emits the binary alongside the addon's package.json
|
||||
// at apps/desktop/native/audio-loopback/audio-loopback.<triple>.node,
|
||||
// and writes an index.js shim that auto-selects the right triple.
|
||||
// Loading the shim works in both layouts when the binary sits
|
||||
// adjacent to it; for the packaged single-file layout we require the
|
||||
// .node directly.
|
||||
const candidates: string[] = [];
|
||||
if (app.isPackaged && process.resourcesPath) {
|
||||
candidates.push(path.join(process.resourcesPath, 'native', 'audio-loopback.node'));
|
||||
}
|
||||
// Dev workspace layout — main bundle lives at out/main/main.js,
|
||||
// addon lives at native/audio-loopback/. Walk up two levels from the
|
||||
// bundle dir (out/main → out → apps/desktop) and into native/.
|
||||
candidates.push(
|
||||
path.join(__dirnameSafe, '..', '..', 'native', 'audio-loopback', 'index.js'),
|
||||
// Direct .node fallback in case the JS shim is missing (e.g. user
|
||||
// ran `cargo build` manually instead of `napi build`).
|
||||
path.join(
|
||||
__dirnameSafe,
|
||||
'..',
|
||||
'..',
|
||||
'native',
|
||||
'audio-loopback',
|
||||
`audio-loopback.${process.platform}-${process.arch}-msvc.node`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const mod = requireCjs(candidate) as NativeAudioLoopback;
|
||||
if (
|
||||
typeof mod.startCapture === 'function' &&
|
||||
typeof mod.stopCapture === 'function'
|
||||
) {
|
||||
cachedAddon = mod;
|
||||
return mod;
|
||||
}
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Track active capture ids per BrowserWindow so we can tear them down
|
||||
* if the renderer is destroyed mid-capture (renderer crash, window
|
||||
* close during share). Without this the WASAPI thread would leak. */
|
||||
const activeCaptures = new Map<number, Set<number>>();
|
||||
|
||||
function registerWindowCleanup(win: BrowserWindow, addon: NativeAudioLoopback): void {
|
||||
const wcId = win.webContents.id;
|
||||
if (activeCaptures.has(wcId)) return;
|
||||
activeCaptures.set(wcId, new Set());
|
||||
const cleanup = (): void => {
|
||||
const ids = activeCaptures.get(wcId);
|
||||
if (!ids) return;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
addon.stopCapture(id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
activeCaptures.delete(wcId);
|
||||
};
|
||||
win.webContents.once('destroyed', cleanup);
|
||||
win.once('closed', cleanup);
|
||||
}
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
ipcMain.handle(
|
||||
CHANNELS.AUDIO_LOOPBACK_START,
|
||||
async (event): Promise<AudioLoopbackStartResult> => {
|
||||
const addon = resolveAddon();
|
||||
if (!addon) {
|
||||
throw new Error('audio-loopback native addon unavailable on this platform');
|
||||
}
|
||||
const wc = event.sender;
|
||||
// captureId is assigned synchronously by addon.startCapture below,
|
||||
// but the chunk callback needs to reference it — we forward-declare
|
||||
// via a closure-shared holder. The first chunk can only fire after
|
||||
// startCapture returns (the worker thread spawn happens inside it).
|
||||
let assignedId = 0;
|
||||
const cb = makeChunkCallback(wc, () => assignedId);
|
||||
assignedId = addon.startCapture(cb);
|
||||
registerWindowCleanup(mainWindow, addon);
|
||||
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||
if (set) set.add(assignedId);
|
||||
return { captureId: assignedId };
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW,
|
||||
async (
|
||||
event,
|
||||
args: { hwnd: number },
|
||||
): Promise<AudioLoopbackStartResult> => {
|
||||
const addon = resolveAddon();
|
||||
if (!addon) {
|
||||
throw new Error('audio-loopback native addon unavailable on this platform');
|
||||
}
|
||||
if (!addon.startCaptureForPid || !addon.resolveWindowPid) {
|
||||
throw new Error(
|
||||
'audio-loopback native addon is too old: missing startCaptureForPid / resolveWindowPid (rebuild with `pnpm build:native`)',
|
||||
);
|
||||
}
|
||||
if (!args || typeof args.hwnd !== 'number' || !Number.isFinite(args.hwnd)) {
|
||||
throw new Error('AUDIO_LOOPBACK_START_FOR_WINDOW: hwnd must be a finite number');
|
||||
}
|
||||
const pid = addon.resolveWindowPid(args.hwnd);
|
||||
const wc = event.sender;
|
||||
let assignedId = 0;
|
||||
const cb = makeChunkCallback(wc, () => assignedId);
|
||||
assignedId = addon.startCaptureForPid(pid, cb);
|
||||
registerWindowCleanup(mainWindow, addon);
|
||||
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||
if (set) set.add(assignedId);
|
||||
return { captureId: assignedId };
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
CHANNELS.AUDIO_LOOPBACK_STOP,
|
||||
async (_event, captureId: number): Promise<void> => {
|
||||
const addon = resolveAddon();
|
||||
if (!addon) return;
|
||||
try {
|
||||
addon.stopCapture(captureId);
|
||||
} finally {
|
||||
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||
if (set) set.delete(captureId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared chunk-forwarding callback. Both start variants produce the
|
||||
* same wire format (interleaved f32 stereo @ 48kHz) so the renderer
|
||||
* doesn't need to know which start path was used — the captureId
|
||||
* routes the chunks. */
|
||||
function makeChunkCallback(
|
||||
wc: Electron.WebContents,
|
||||
getCaptureId: () => number,
|
||||
): (samples: Float32Array) => void {
|
||||
return (samples: Float32Array): void => {
|
||||
// The addon delivers each chunk on its WASAPI capture thread —
|
||||
// marshal to the renderer's webContents from the main loop. If the
|
||||
// webContents has been destroyed (window closed during a share)
|
||||
// silently drop; the cleanup hook will stop the capture.
|
||||
if (wc.isDestroyed()) return;
|
||||
const captureId = getCaptureId();
|
||||
const payload: AudioLoopbackChunk = { captureId, samples };
|
||||
wc.send(CHANNELS.AUDIO_LOOPBACK_CHUNK, payload);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user