fix: restore setDisplayMediaRequestHandler with useSystemPicker
Electron requires setDisplayMediaRequestHandler for getDisplayMedia() to work — removing it broke screen sharing entirely. Restored the handler with useSystemPicker: true, which on macOS 15+ uses the native system picker (honoring restrictOwnAudio), while Windows/Linux fall back to the custom picker with the shareAudio toggle for echo control.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
screen,
|
screen,
|
||||||
session,
|
session,
|
||||||
|
desktopCapturer,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
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 ────────────────────────────────────────────────────────────
|
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||||
@@ -591,10 +597,68 @@ if (!gotTheLock) {
|
|||||||
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
|
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
|
||||||
await session.defaultSession.clearCache();
|
await session.defaultSession.clearCache();
|
||||||
|
|
||||||
// Screen sharing: let Chromium's native getDisplayMedia() pipeline handle
|
// Intercept getDisplayMedia() — show custom picker in renderer.
|
||||||
// source selection and audio capture. This ensures restrictOwnAudio is
|
// useSystemPicker: on macOS 15+, the native system picker runs instead of
|
||||||
// applied natively, preventing the app's own voice playback from being
|
// our handler, allowing Chromium's restrictOwnAudio constraint to work
|
||||||
// captured in the system audio loopback.
|
// 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();
|
registerIpcHandlers();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
ipcRenderer.on('deep-link', (_event, url) => callback(url));
|
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
|
// Instance URL management
|
||||||
getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'),
|
getInstanceUrl: () => ipcRenderer.invoke('get-instance-url'),
|
||||||
setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url),
|
setInstanceUrl: (url: string) => ipcRenderer.invoke('set-instance-url', url),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RegisterPage } from './components/auth/RegisterPage';
|
|||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
import { JoinPage } from './components/JoinPage';
|
import { JoinPage } from './components/JoinPage';
|
||||||
import { SwAutoUpdate } from './components/ui/SwUpdatePrompt';
|
import { SwAutoUpdate } from './components/ui/SwUpdatePrompt';
|
||||||
|
import { ScreenSharePicker } from './components/voice/ScreenSharePicker';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
import { isElectron } from './platform/platform';
|
import { isElectron } from './platform/platform';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export function App() {
|
|||||||
</>}
|
</>}
|
||||||
<div className={showTitleBar ? 'flex-1 min-h-0' : 'contents'}>
|
<div className={showTitleBar ? 'flex-1 min-h-0' : 'contents'}>
|
||||||
<SwAutoUpdate />
|
<SwAutoUpdate />
|
||||||
|
<ScreenSharePicker />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { getElectronAPI } from '../../platform/platform';
|
||||||
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zustand micro-store — bridges the event-driven API to React state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ScreenPickerState {
|
||||||
|
isOpen: boolean;
|
||||||
|
sources: ElectronScreenSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const useScreenPickerStore = create<ScreenPickerState>(() => ({
|
||||||
|
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<Tab>('screens');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={() => closePicker(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal card */}
|
||||||
|
<div className="relative w-full max-w-3xl mx-4 glass-modal rounded-lg animate-slide-up flex flex-col max-h-[calc(100vh-4rem)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-bold text-txt-primary">Share Your Screen</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => closePicker(null)}
|
||||||
|
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 px-5 pb-3 flex-shrink-0">
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'screens'}
|
||||||
|
onClick={() => { setActiveTab('screens'); setSelectedId(null); }}
|
||||||
|
label="Screens"
|
||||||
|
count={screens.length}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'windows'}
|
||||||
|
onClick={() => { setActiveTab('windows'); setSelectedId(null); }}
|
||||||
|
label="Windows"
|
||||||
|
count={windows.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search (windows tab only) */}
|
||||||
|
{activeTab === 'windows' && (
|
||||||
|
<div className="px-5 pb-3 flex-shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search windows..."
|
||||||
|
className="input-search w-full"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source grid */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-5 py-2">
|
||||||
|
{activeSources.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-txt-tertiary text-sm">
|
||||||
|
{activeTab === 'windows' && search.trim()
|
||||||
|
? 'No windows match your search'
|
||||||
|
: `No ${activeTab} available`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`grid gap-3 ${activeTab === 'screens' ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
||||||
|
{activeSources.map((source) => (
|
||||||
|
<SourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
selected={selectedId === source.id}
|
||||||
|
onClick={() => setSelectedId(source.id)}
|
||||||
|
onDoubleClick={() => closePicker(source.id, shareAudio)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-center px-5 pt-2 pb-4">
|
||||||
|
<div className="flex flex-col items-center gap-1 mb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shareAudio}
|
||||||
|
onChange={(e) => setScreenShareConfig({ shareAudio: e.target.checked })}
|
||||||
|
className="w-3.5 h-3.5 rounded accent-accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-txt-secondary">Share system audio</span>
|
||||||
|
</label>
|
||||||
|
{shareAudio && (
|
||||||
|
<div className="text-[11px] text-accent-amber/80">
|
||||||
|
Headphones recommended to prevent echo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => closePicker(null)}
|
||||||
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => closePicker(selectedId, shareAudio)}
|
||||||
|
disabled={!selectedId}
|
||||||
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, label, count }: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-accent-primary text-white'
|
||||||
|
: 'bg-white/[0.06] text-txt-secondary hover:text-txt-primary hover:bg-white/[0.1]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className={`ml-1.5 text-xs ${active ? 'text-white/70' : 'text-txt-tertiary'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceCard({ source, selected, onClick, onDoubleClick }: {
|
||||||
|
source: ElectronScreenSource;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onDoubleClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
className={`group flex flex-col rounded-lg overflow-hidden transition-all text-left border-2 ${
|
||||||
|
selected
|
||||||
|
? 'border-accent-primary bg-accent-primary/10'
|
||||||
|
: 'border-white/[0.06] hover:border-border-soft bg-surface-base hover:bg-white/[0.04] hover:brightness-110'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="relative aspect-video bg-black/40 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={source.thumbnailDataUrl}
|
||||||
|
alt={source.name}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 py-2 min-w-0">
|
||||||
|
{source.appIconDataUrl && (
|
||||||
|
<img
|
||||||
|
src={source.appIconDataUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 flex-shrink-0"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs truncate ${selected ? 'text-txt-primary' : 'text-txt-secondary'}`}>
|
||||||
|
{source.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
@@ -1,5 +1,13 @@
|
|||||||
/** Type augmentation for the Electron IPC bridge exposed by preload.ts */
|
/** 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 {
|
interface BackspaceElectronAPI {
|
||||||
// Platform info
|
// Platform info
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
@@ -26,6 +34,10 @@ interface BackspaceElectronAPI {
|
|||||||
// Deep linking (Task 2.3)
|
// Deep linking (Task 2.3)
|
||||||
onDeepLink: (callback: (url: string) => void) => void;
|
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
|
// Instance URL management
|
||||||
getInstanceUrl: () => Promise<string | null>;
|
getInstanceUrl: () => Promise<string | null>;
|
||||||
setInstanceUrl: (url: string) => Promise<void>;
|
setInstanceUrl: (url: string) => Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user