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,154 @@
|
||||
// Preload bridge. Exposes a single `window.electronAPI` object to the
|
||||
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
|
||||
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
|
||||
// three event channels (shortcut fired/released + updater progress) get
|
||||
// `on*` subscribers that return an unsubscribe function.
|
||||
//
|
||||
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
|
||||
// Node directly — only this curated surface.
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
import {
|
||||
CHANNELS,
|
||||
ELECTRON_RUNTIME_MARKER,
|
||||
type AudioLoopbackChunk,
|
||||
type AudioLoopbackStartResult,
|
||||
type FsPath,
|
||||
type FsRenameArgs,
|
||||
type FsWriteArgs,
|
||||
type NotifyArgs,
|
||||
type ScreenSource,
|
||||
type SecureStoreHandle,
|
||||
type SecureStoreOpenArgs,
|
||||
type ShortcutEvent,
|
||||
type ShortcutRegisterArgs,
|
||||
type SqlExecuteResult,
|
||||
type SqlSelectResult,
|
||||
type UpdateCheckResult,
|
||||
type UpdateProgress,
|
||||
} from './ipc-types';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
|
||||
ipcRenderer.on(channel, handler);
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
}
|
||||
|
||||
const api = {
|
||||
platform: ELECTRON_RUNTIME_MARKER,
|
||||
osPlatform: process.platform as NodeJS.Platform,
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
|
||||
// Screen sources ---------------------------------------------------------
|
||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
|
||||
/** Tell main which source the user picked in our Discord-style modal,
|
||||
* BEFORE calling getDisplayMedia. The display-media handler reads this
|
||||
* and routes the matching desktopCapturer Source into LiveKit. Pass
|
||||
* null on cancel/error to clear the pending state. */
|
||||
setPendingShareSource: (sourceId: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
|
||||
|
||||
// Audio loopback ---------------------------------------------------------
|
||||
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
|
||||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
|
||||
|
||||
/** Native WASAPI process-loopback (Windows only). Captures all system
|
||||
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
|
||||
* back. Throws on platforms where the addon isn't available — caller
|
||||
* is expected to fall back to the renderer-driven getUserMedia path
|
||||
* (see lib/screenAudio.ts). */
|
||||
audioLoopback: {
|
||||
start: (): Promise<AudioLoopbackStartResult> =>
|
||||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
|
||||
/** Window-share variant: capture only the picked window's process
|
||||
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
|
||||
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
|
||||
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
|
||||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
|
||||
stop: (captureId: number): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
|
||||
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
|
||||
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
|
||||
},
|
||||
|
||||
// Shortcuts --------------------------------------------------------------
|
||||
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
|
||||
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
|
||||
unregisterShortcut: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
|
||||
isShortcutRegistered: (id: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
|
||||
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||||
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
|
||||
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||||
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
|
||||
|
||||
// Notifications ----------------------------------------------------------
|
||||
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
|
||||
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
|
||||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||
|
||||
// Tray -------------------------------------------------------------------
|
||||
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
|
||||
|
||||
// Secure store -----------------------------------------------------------
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
|
||||
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
|
||||
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
|
||||
secureStoreRemove: (handle: string, key: string): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
|
||||
secureStoreClose: (handle: string): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
|
||||
|
||||
// Filesystem -------------------------------------------------------------
|
||||
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
|
||||
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
|
||||
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
|
||||
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
|
||||
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
|
||||
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
|
||||
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
|
||||
|
||||
// SQL --------------------------------------------------------------------
|
||||
sqlLoad: (args: { name: string }): Promise<string> =>
|
||||
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
|
||||
sqlExecute: (args: {
|
||||
handle: string;
|
||||
query: string;
|
||||
bindings?: unknown[];
|
||||
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
|
||||
sqlSelect: (args: {
|
||||
handle: string;
|
||||
query: string;
|
||||
bindings?: unknown[];
|
||||
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
|
||||
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
|
||||
|
||||
// Updater ----------------------------------------------------------------
|
||||
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
|
||||
downloadInstallUpdate: (): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
|
||||
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
|
||||
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
|
||||
|
||||
// Autostart --------------------------------------------------------------
|
||||
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
|
||||
setAutoStart: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
|
||||
|
||||
// Window fullscreen ------------------------------------------------------
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||
} as const;
|
||||
|
||||
export type ElectronAPI = typeof api;
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api);
|
||||
Reference in New Issue
Block a user