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;