diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts
index 6ca76686..dd4ae259 100644
--- a/packages/desktop/src/main.ts
+++ b/packages/desktop/src/main.ts
@@ -9,6 +9,7 @@ import {
shell,
screen,
session,
+ desktopCapturer,
} from 'electron';
import path from 'path';
import fs from 'fs';
@@ -430,6 +431,11 @@ 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 ────────────────────────────────────────────────────────────
@@ -591,10 +597,68 @@ if (!gotTheLock) {
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
await session.defaultSession.clearCache();
- // 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.
+ // Intercept getDisplayMedia() — show custom picker in renderer.
+ // useSystemPicker: on macOS 15+, the native system picker runs instead of
+ // our handler, allowing Chromium's restrictOwnAudio constraint to work
+ // natively (no audio feedback). On Windows/Linux the handler is called.
+ 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 — 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
+ callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
+ } catch (err) {
+ console.error('[Main:ScreenShare] Handler error:', err);
+ // @ts-ignore — deny the request without crashing
+ callback();
+ }
+ }, { useSystemPicker: true });
registerIpcHandlers();
createWindow();
diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts
index e21e0802..a22e652d 100644
--- a/packages/desktop/src/preload.ts
+++ b/packages/desktop/src/preload.ts
@@ -50,6 +50,14 @@ 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 080ba333..6e87adc5 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -5,6 +5,7 @@ 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';
@@ -38,6 +39,7 @@ 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 35467156..6bfd1a4c 100644
--- a/packages/web/src/platform/electron.d.ts
+++ b/packages/web/src/platform/electron.d.ts
@@ -1,5 +1,13 @@
/** 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;
@@ -26,6 +34,10 @@ 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;