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;