+ {/* 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)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Footer */}
+
+ {isMac ? (
+
System audio is not available on macOS
+ ) : (
+
System audio will be shared
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx
index 046fc922..facdfca2 100644
--- a/packages/web/src/components/voice/VoiceControls.tsx
+++ b/packages/web/src/components/voice/VoiceControls.tsx
@@ -59,6 +59,7 @@ export function VoiceControls() {
const handleScreenShare = async () => {
const room = getActiveRoom();
+ console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing);
if (!room) return;
try {
if (!isScreenSharing) {
diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts
index 69af55b5..d49e08f0 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;
@@ -25,6 +33,15 @@ 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) => void;
+
+ // Instance URL management
+ getInstanceUrl: () => Promise
;
+ setInstanceUrl: (url: string) => Promise;
+ clearInstanceUrl: () => Promise;
}
interface Window {
diff --git a/packages/web/src/platform/platform.ts b/packages/web/src/platform/platform.ts
index 3739b4ea..d49a7887 100644
--- a/packages/web/src/platform/platform.ts
+++ b/packages/web/src/platform/platform.ts
@@ -2,6 +2,10 @@ export function isElectron(): boolean {
return typeof window !== 'undefined' && typeof window.backspace !== 'undefined';
}
+export function isElectronMac(): boolean {
+ return isElectron() && window.backspace?.platform === 'darwin';
+}
+
export function getElectronAPI(): BackspaceElectronAPI | null {
return window.backspace ?? null;
}
diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css
index f5758d52..437bfd40 100644
--- a/packages/web/src/styles/globals.css
+++ b/packages/web/src/styles/globals.css
@@ -455,3 +455,7 @@
.animate-call-button-glow { animation: none !important; }
.call-refraction::after { animation: none !important; opacity: 0; }
}
+
+/* ── Electron window drag regions ── */
+.titlebar-drag { -webkit-app-region: drag; }
+.titlebar-no-drag { -webkit-app-region: no-drag; }
diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts
index 07d3c79c..95b43ac2 100644
--- a/packages/web/src/utils/screenShare.ts
+++ b/packages/web/src/utils/screenShare.ts
@@ -124,10 +124,13 @@ export async function applyOverdrive(
}
// ---------------------------------------------------------------------------
-// Start screen sharing
+// Start screen sharing — single path via setScreenShareEnabled()
+// In Electron, getDisplayMedia() is intercepted by setDisplayMediaRequestHandler
+// in the main process, which shows the custom picker automatically.
// ---------------------------------------------------------------------------
export async function startScreenShare(room: Room): Promise {
+ console.log('[SS] startScreenShare called, room state:', room.state);
const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
try {
@@ -147,6 +150,7 @@ export async function startScreenShare(room: Room): Promise {
simulcast: opts.publish.simulcast,
} as any);
+ console.log('[SS] setScreenShareEnabled returned:', !!track);
if (!track) {
return false;
}
@@ -159,34 +163,7 @@ export async function startScreenShare(room: Room): Promise {
}
useVoiceStore.setState({ isScreenSharing: true });
-
- // Overdrive at 2s — after WebRTC finishes negotiation
- setTimeout(async () => {
- if (!useVoiceStore.getState().isScreenSharing) return;
- // Rebuild from fresh store state — no stale closures
- const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
-
- const screenPub = room.localParticipant.getTrackPublications()
- .find(p => p.source === Track.Source.ScreenShare);
- if (screenPub?.track?.mediaStreamTrack) {
- await screenPub.track.mediaStreamTrack.applyConstraints({
- width: { ideal: freshOpts.capture.width },
- height: { ideal: freshOpts.capture.height },
- frameRate: { ideal: freshOpts.capture.frameRate, min: 15 },
- });
- // Re-assert contentHint (LiveKit may strip it during renegotiation)
- screenPub.track.mediaStreamTrack.contentHint = freshOpts.contentHint;
- }
- await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
- }, 2000);
-
- // Second overdrive at 5s — safety net for slow BWE convergence
- setTimeout(async () => {
- if (!useVoiceStore.getState().isScreenSharing) return;
- const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
- await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
- }, 5000);
-
+ applyScreenShareOverdrive(room);
return true;
} catch (err) {
console.error('[ScreenShare] Failed to start screen share:', err);
@@ -194,6 +171,37 @@ export async function startScreenShare(room: Room): Promise {
}
}
+// ---------------------------------------------------------------------------
+// Shared overdrive scheduling
+// ---------------------------------------------------------------------------
+
+function applyScreenShareOverdrive(room: Room): void {
+ // Overdrive at 2s — after WebRTC finishes negotiation
+ setTimeout(async () => {
+ if (!useVoiceStore.getState().isScreenSharing) return;
+ const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
+
+ const screenPub = room.localParticipant.getTrackPublications()
+ .find(p => p.source === Track.Source.ScreenShare);
+ if (screenPub?.track?.mediaStreamTrack) {
+ await screenPub.track.mediaStreamTrack.applyConstraints({
+ width: { ideal: freshOpts.capture.width },
+ height: { ideal: freshOpts.capture.height },
+ frameRate: { ideal: freshOpts.capture.frameRate, min: 15 },
+ });
+ screenPub.track.mediaStreamTrack.contentHint = freshOpts.contentHint;
+ }
+ await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
+ }, 2000);
+
+ // Second overdrive at 5s — safety net for slow BWE convergence
+ setTimeout(async () => {
+ if (!useVoiceStore.getState().isScreenSharing) return;
+ const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
+ await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
+ }, 5000);
+}
+
// ---------------------------------------------------------------------------
// Stop screen sharing
// ---------------------------------------------------------------------------
@@ -212,7 +220,7 @@ export async function stopScreenShare(room: Room): Promise {
// ---------------------------------------------------------------------------
export async function changeScreenShare(room: Room): Promise {
- await room.localParticipant.setScreenShareEnabled(false);
+ await stopScreenShare(room);
setTimeout(async () => {
await startScreenShare(room);
}, 200);