fix: eliminate screen share audio feedback loop + upgrade Electron 33→40
Screen sharing with audio captured the app's own voice playback, causing users to hear themselves echoed back. Fixed via two layers: - Add restrictOwnAudio constraint (Chrome 141+/Chromium 144) to exclude the app's own audio from system audio capture - Add shareAudio toggle so users can disable system audio entirely - Remove outdated macOS audio block (now supported via ScreenCaptureKit) - Upgrade Electron 33→40 (Chromium 130→144) so restrictOwnAudio works natively in the desktop app - Add NSAudioCaptureUsageDescription for macOS 14.2+ audio capture - Add GTK 3 fallback for Linux GNOME compatibility (Electron 36+)
This commit is contained in:
@@ -17,6 +17,9 @@ protocols:
|
||||
- backspace
|
||||
mac:
|
||||
category: public.app-category.social-networking
|
||||
minimumSystemVersion: "12.0"
|
||||
extendInfo:
|
||||
NSAudioCaptureUsageDescription: "Backspace needs access to system audio to share sound during screen sharing."
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"electron-updater": "^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^33.2.0",
|
||||
"electron": "^40.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ function registerIpcHandlers(): void {
|
||||
});
|
||||
|
||||
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
||||
ipcMain.on('screen-share-selected', (_event, sourceId: string | null) => {
|
||||
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
|
||||
});
|
||||
@@ -487,6 +487,12 @@ function handleDeepLink(url: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Electron 36+ defaults to GTK 4 on GNOME, which crashes if GTK 2/3
|
||||
// libraries are loaded in the same process. Force GTK 3 for compatibility.
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
}
|
||||
|
||||
// Set as default protocol handler
|
||||
app.setAsDefaultProtocolClient('backspace');
|
||||
|
||||
@@ -621,12 +627,12 @@ if (!gotTheLock) {
|
||||
// Send sources to renderer, wait for user selection
|
||||
mainWindow?.webContents.send('screen-share-sources', serialized);
|
||||
|
||||
const sourceId = await new Promise<string | null>((resolve) => {
|
||||
ipcMain.once('screen-share-selected', (_event, id: string | null) => {
|
||||
resolve(id);
|
||||
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);
|
||||
console.log('[Main:ScreenShare] User selected:', sourceId, 'audio:', shareAudio);
|
||||
|
||||
if (!sourceId) {
|
||||
// @ts-ignore — deny the request without crashing
|
||||
@@ -642,12 +648,9 @@ if (!gotTheLock) {
|
||||
}
|
||||
|
||||
// Provide the selected source — Electron creates the MediaStream
|
||||
// Enable system audio loopback on Windows/Linux (macOS blocks at OS level)
|
||||
if (process.platform === 'darwin') {
|
||||
callback({ video: selected });
|
||||
} else {
|
||||
callback({ video: selected, audio: 'loopback' });
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -54,8 +54,8 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
||||
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
||||
},
|
||||
selectScreenSource: (sourceId: string | null) => {
|
||||
ipcRenderer.send('screen-share-selected', sourceId);
|
||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
||||
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
||||
},
|
||||
|
||||
// Instance URL management
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -20,9 +21,9 @@ const useScreenPickerStore = create<ScreenPickerState>(() => ({
|
||||
// Close helper — sends selection back to main process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function closePicker(sourceId: string | null) {
|
||||
function closePicker(sourceId: string | null, shareAudio?: boolean) {
|
||||
const api = getElectronAPI();
|
||||
if (api) api.selectScreenSource(sourceId);
|
||||
if (api) api.selectScreenSource(sourceId, shareAudio);
|
||||
useScreenPickerStore.setState({ isOpen: false, sources: [] });
|
||||
}
|
||||
|
||||
@@ -37,6 +38,8 @@ export function ScreenSharePicker() {
|
||||
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(() => {
|
||||
@@ -93,8 +96,6 @@ export function ScreenSharePicker() {
|
||||
return wins.filter((w) => w.name.toLowerCase().includes(q));
|
||||
}, [sources, search]);
|
||||
|
||||
const isMac = getElectronAPI()?.platform === 'darwin';
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeSources = activeTab === 'screens' ? screens : windows;
|
||||
@@ -168,7 +169,7 @@ export function ScreenSharePicker() {
|
||||
source={source}
|
||||
selected={selectedId === source.id}
|
||||
onClick={() => setSelectedId(source.id)}
|
||||
onDoubleClick={() => closePicker(source.id)}
|
||||
onDoubleClick={() => closePicker(source.id, shareAudio)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,11 +178,22 @@ export function ScreenSharePicker() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-shrink-0 flex flex-col items-center px-5 pt-2 pb-4">
|
||||
{isMac ? (
|
||||
<div className="text-[11px] text-txt-tertiary mb-2">System audio is not available on macOS</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-txt-tertiary mb-2">System audio will be shared</div>
|
||||
)}
|
||||
<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)}
|
||||
@@ -190,7 +202,7 @@ export function ScreenSharePicker() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => closePicker(selectedId)}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ScreenShareConfig } from '../../stores/voiceStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
import { Toggle } from '../ui/Toggle';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
|
||||
interface ScreenShareSettingsPopoverProps {
|
||||
open: boolean;
|
||||
@@ -217,6 +219,26 @@ export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Audio */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider">
|
||||
System Audio
|
||||
</div>
|
||||
{isElectron() && config.shareAudio && (
|
||||
<div className="text-[10px] text-accent-amber/80 mt-0.5">
|
||||
Headphones recommended
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={config.shareAudio}
|
||||
onChange={(enabled) => setConfig({ shareAudio: enabled })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — computed stats */}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ interface BackspaceElectronAPI {
|
||||
|
||||
// Screen share picker coordination
|
||||
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
|
||||
selectScreenSource: (sourceId: string | null) => void;
|
||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
|
||||
|
||||
// Instance URL management
|
||||
getInstanceUrl: () => Promise<string | null>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ScreenShareConfig {
|
||||
fps: 60 | 45 | 30;
|
||||
mode: 'gaming' | 'text';
|
||||
customBitrateKbps: number | null;
|
||||
shareAudio: boolean;
|
||||
}
|
||||
|
||||
interface VoiceState {
|
||||
@@ -134,7 +135,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
inputDeviceId: 'default',
|
||||
outputDeviceId: 'default',
|
||||
focusedParticipantId: null,
|
||||
screenShareConfig: { height: 720, fps: 60, mode: 'gaming', customBitrateKbps: null },
|
||||
screenShareConfig: { height: 720, fps: 60, mode: 'gaming', customBitrateKbps: null, shareAudio: true },
|
||||
participantVolumes: new Map(),
|
||||
setParticipantVolume: (userId, volume) => {
|
||||
set((state) => {
|
||||
@@ -527,7 +528,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
}),
|
||||
{
|
||||
name: 'backspace-voice-settings',
|
||||
version: 9,
|
||||
version: 10,
|
||||
migrate: (persistedState: any, version: number) => {
|
||||
if (version === 0) {
|
||||
persistedState.streamAttenuationEnabled = false;
|
||||
@@ -566,6 +567,11 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
if (version < 9) {
|
||||
delete persistedState.currentVoiceChannelId;
|
||||
}
|
||||
if (version < 10) {
|
||||
if (persistedState.screenShareConfig) {
|
||||
persistedState.screenShareConfig.shareAudio = true;
|
||||
}
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
|
||||
@@ -131,16 +131,22 @@ export async function applyOverdrive(
|
||||
|
||||
export async function startScreenShare(room: Room): Promise<boolean> {
|
||||
console.log('[SS] startScreenShare called, room state:', room.state);
|
||||
const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
|
||||
const config = useVoiceStore.getState().screenShareConfig;
|
||||
const opts = buildScreenShareOptions(config);
|
||||
|
||||
try {
|
||||
const track = await room.localParticipant.setScreenShareEnabled(true, {
|
||||
audio: {
|
||||
audio: config.shareAudio ? {
|
||||
// Chrome 141+: exclude this tab's own audio from system audio capture
|
||||
// Prevents feedback loop where remote voices are captured and echoed back
|
||||
// Silently ignored by older browsers / Electron's Chromium 130
|
||||
// @ts-ignore — restrictOwnAudio is not yet in all TS type definitions
|
||||
restrictOwnAudio: true,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
channelCount: 2,
|
||||
},
|
||||
} : false,
|
||||
resolution: { width: opts.capture.width, height: opts.capture.height },
|
||||
// @ts-ignore — LiveKit accepts frameRate at capture level
|
||||
frameRate: opts.capture.frameRate,
|
||||
|
||||
Reference in New Issue
Block a user