From 9ff376164098b267f450e8c28d89aef666d8826c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:18:42 +0100 Subject: [PATCH] fix: remove setDisplayMediaRequestHandler to let restrictOwnAudio work natively The custom screen share picker intercepted getDisplayMedia() and created a raw loopback stream, bypassing Chromium's constraint pipeline entirely. restrictOwnAudio was silently discarded. Removing the handler lets Chromium 144's native getDisplayMedia run end-to-end with restrictOwnAudio applied, eliminating the audio feedback loop in the desktop app. --- packages/desktop/src/main.ts | 70 +---- packages/desktop/src/preload.ts | 8 - packages/web/src/App.tsx | 2 - .../components/voice/ScreenSharePicker.tsx | 289 ------------------ packages/web/src/platform/electron.d.ts | 12 - 5 files changed, 4 insertions(+), 377 deletions(-) delete mode 100644 packages/web/src/components/voice/ScreenSharePicker.tsx diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e09650a4..6ca76686 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -9,7 +9,6 @@ import { shell, screen, session, - desktopCapturer, } from 'electron'; import path from 'path'; import fs from 'fs'; @@ -431,11 +430,6 @@ function registerIpcHandlers(): void { } }); - // Screen share picker coordination (used by setDisplayMediaRequestHandler) - ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => { - // Handled via ipcMain.once in the display media handler — this is just - // a safety net to prevent unhandled-message warnings - }); } // ─── Auto-Update ──────────────────────────────────────────────────────────── @@ -597,66 +591,10 @@ if (!gotTheLock) { await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] }); await session.defaultSession.clearCache(); - // Intercept getDisplayMedia() — show custom picker in renderer - session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { - console.log('[Main:ScreenShare] Handler invoked'); - try { - const sources = await desktopCapturer.getSources({ - types: ['screen', 'window'], - thumbnailSize: { width: 320, height: 180 }, - fetchWindowIcons: true, - }); - console.log('[Main:ScreenShare] Got', sources.length, 'sources'); - - if (sources.length === 0) { - console.warn('[Main:ScreenShare] No sources — macOS Screen Recording permission may not be granted'); - // @ts-ignore — Electron throws if we pass {} when video was requested; pass nothing to deny - callback(); - return; - } - - const serialized = sources.map((source) => ({ - id: source.id, - name: source.name, - thumbnailDataUrl: source.thumbnail.toDataURL(), - appIconDataUrl: source.appIcon && !source.appIcon.isEmpty() - ? source.appIcon.toDataURL() : null, - isScreen: source.id.startsWith('screen:'), - })); - - // Send sources to renderer, wait for user selection - mainWindow?.webContents.send('screen-share-sources', serialized); - - const { sourceId, shareAudio } = await new Promise<{ sourceId: string | null; shareAudio: boolean }>((resolve) => { - ipcMain.once('screen-share-selected', (_event, id: string | null, wantAudio?: boolean) => { - resolve({ sourceId: id, shareAudio: wantAudio ?? true }); - }); - }); - console.log('[Main:ScreenShare] User selected:', sourceId, 'audio:', shareAudio); - - if (!sourceId) { - // @ts-ignore — deny the request without crashing - callback(); - return; - } - - const selected = sources.find((s) => s.id === sourceId); - if (!selected) { - // @ts-ignore — deny the request without crashing - callback(); - return; - } - - // Provide the selected source — Electron creates the MediaStream - // System audio loopback: Windows/Linux native, macOS 13+ via ScreenCaptureKit - // Controlled by user's shareAudio preference from the picker UI - callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) }); - } catch (err) { - console.error('[Main:ScreenShare] Handler error:', err); - // @ts-ignore — deny the request without crashing - callback(); - } - }); + // Screen sharing: let Chromium's native getDisplayMedia() pipeline handle + // source selection and audio capture. This ensures restrictOwnAudio is + // applied natively, preventing the app's own voice playback from being + // captured in the system audio loopback. registerIpcHandlers(); createWindow(); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index a22e652d..e21e0802 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -50,14 +50,6 @@ contextBridge.exposeInMainWorld('backspace', { ipcRenderer.on('deep-link', (_event, url) => callback(url)); }, - // Screen share picker coordination - onScreenShareSources: (callback: (sources: unknown[]) => void) => { - ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources)); - }, - selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => { - ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true); - }, - // Instance URL management getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'), setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url), diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 6e87adc5..080ba333 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,7 +5,6 @@ import { RegisterPage } from './components/auth/RegisterPage'; import { AppLayout } from './components/layout/AppLayout'; import { JoinPage } from './components/JoinPage'; import { SwAutoUpdate } from './components/ui/SwUpdatePrompt'; -import { ScreenSharePicker } from './components/voice/ScreenSharePicker'; import { useAuthStore } from './stores/authStore'; import { isElectron } from './platform/platform'; @@ -39,7 +38,6 @@ export function App() { }
- (() => ({ - isOpen: false, - sources: [], -})); - -// --------------------------------------------------------------------------- -// Close helper — sends selection back to main process -// --------------------------------------------------------------------------- - -function closePicker(sourceId: string | null, shareAudio?: boolean) { - const api = getElectronAPI(); - if (api) api.selectScreenSource(sourceId, shareAudio); - useScreenPickerStore.setState({ isOpen: false, sources: [] }); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -type Tab = 'screens' | 'windows'; - -export function ScreenSharePicker() { - const { isOpen, sources } = useScreenPickerStore(); - const [activeTab, setActiveTab] = useState('screens'); - const [selectedId, setSelectedId] = useState(null); - const [search, setSearch] = useState(''); - const shareAudio = useVoiceStore((s) => s.screenShareConfig.shareAudio); - const setScreenShareConfig = useVoiceStore((s) => s.setScreenShareConfig); - - // Register listener for sources from main process (once on mount) - useEffect(() => { - const api = getElectronAPI(); - console.log('[Picker] Mounted, registering onScreenShareSources listener, hasAPI:', !!api); - if (!api) return; - - api.onScreenShareSources((incomingSources) => { - console.log('[Picker] Received', incomingSources.length, 'sources from main process'); - useScreenPickerStore.setState({ - isOpen: true, - sources: incomingSources, - }); - }); - }, []); - - // Reset local state when picker opens - useEffect(() => { - if (isOpen) { - setActiveTab('screens'); - setSelectedId(null); - setSearch(''); - } - }, [isOpen]); - - // Auto-select if there's exactly one screen - useEffect(() => { - if (isOpen && sources.length > 0 && !selectedId) { - const screens = sources.filter((s) => s.isScreen); - if (screens.length === 1 && activeTab === 'screens') { - setSelectedId(screens[0]!.id); - } - } - }, [isOpen, sources, selectedId, activeTab]); - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - closePicker(null); - } - }, []); - - useEffect(() => { - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - } - }, [isOpen, handleKeyDown]); - - const screens = useMemo(() => sources.filter((s) => s.isScreen), [sources]); - const windows = useMemo(() => { - const wins = sources.filter((s) => !s.isScreen); - if (!search.trim()) return wins; - const q = search.trim().toLowerCase(); - return wins.filter((w) => w.name.toLowerCase().includes(q)); - }, [sources, search]); - - if (!isOpen) return null; - - const activeSources = activeTab === 'screens' ? screens : windows; - - return ( -
- {/* Backdrop */} -
closePicker(null)} - /> - - {/* Modal card */} -
- {/* Header */} -
-

Share Your Screen

- -
- - {/* Tabs */} -
- { setActiveTab('screens'); setSelectedId(null); }} - label="Screens" - count={screens.length} - /> - { setActiveTab('windows'); setSelectedId(null); }} - label="Windows" - count={windows.length} - /> -
- - {/* Search (windows tab only) */} - {activeTab === 'windows' && ( -
- setSearch(e.target.value)} - placeholder="Search windows..." - className="input-search w-full" - autoFocus - /> -
- )} - - {/* Source grid */} -
- {activeSources.length === 0 ? ( -
- {activeTab === 'windows' && search.trim() - ? 'No windows match your search' - : `No ${activeTab} available`} -
- ) : ( -
- {activeSources.map((source) => ( - setSelectedId(source.id)} - onDoubleClick={() => closePicker(source.id, shareAudio)} - /> - ))} -
- )} -
- - {/* Footer */} -
-
- - {shareAudio && ( -
- Headphones recommended to prevent echo -
- )} -
-
- - -
-
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function TabButton({ active, onClick, label, count }: { - active: boolean; - onClick: () => void; - label: string; - count: number; -}) { - return ( - - ); -} - -function SourceCard({ source, selected, onClick, onDoubleClick }: { - source: ElectronScreenSource; - selected: boolean; - onClick: () => void; - onDoubleClick: () => void; -}) { - return ( - - ); -} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index 6bfd1a4c..35467156 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -1,13 +1,5 @@ /** Type augmentation for the Electron IPC bridge exposed by preload.ts */ -interface ElectronScreenSource { - id: string; // "screen:0:0" or "window:12345:0" - name: string; // "Entire Screen" or "Firefox" - thumbnailDataUrl: string; // PNG data URL at 320×180 - appIconDataUrl: string | null; // App icon (windows only) - isScreen: boolean; // true = display, false = window -} - interface BackspaceElectronAPI { // Platform info platform: NodeJS.Platform; @@ -34,10 +26,6 @@ interface BackspaceElectronAPI { // Deep linking (Task 2.3) onDeepLink: (callback: (url: string) => void) => void; - // Screen share picker coordination - onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void; - selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void; - // Instance URL management getInstanceUrl: () => Promise; setInstanceUrl: (url: string) => Promise;