diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 80482fc2..4e35d797 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -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 diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4ba83b67..7c7fa78d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index bd528d61..e09650a4 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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((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 diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 21ee5c29..a22e652d 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -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 diff --git a/packages/web/src/components/voice/ScreenSharePicker.tsx b/packages/web/src/components/voice/ScreenSharePicker.tsx index 3ba9d9b4..9b483d91 100644 --- a/packages/web/src/components/voice/ScreenSharePicker.tsx +++ b/packages/web/src/components/voice/ScreenSharePicker.tsx @@ -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(() => ({ // 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('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(() => { @@ -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)} /> ))} @@ -177,11 +178,22 @@ export function ScreenSharePicker() { {/* Footer */}
- {isMac ? ( -
System audio is not available on macOS
- ) : ( -
System audio will be shared
- )} +
+ + {shareAudio && ( +
+ Headphones recommended to prevent echo +
+ )} +
+ + {/* System Audio */} +
+
+
+
+ System Audio +
+ {isElectron() && config.shareAudio && ( +
+ Headphones recommended +
+ )} +
+ setConfig({ shareAudio: enabled })} + /> +
+
{/* Footer — computed stats */} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index d49e08f0..6bfd1a4c 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -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; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 806cdd8f..2a6fcfda 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -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()( 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()( }), { 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()( if (version < 9) { delete persistedState.currentVoiceChannelId; } + if (version < 10) { + if (persistedState.screenShareConfig) { + persistedState.screenShareConfig.shareAudio = true; + } + } return persistedState; }, storage: createJSONStorage(() => localStorage), diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index 95b43ac2..b62d2d06 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -131,16 +131,22 @@ export async function applyOverdrive( export async function startScreenShare(room: Room): Promise { 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26af7f99..9c021cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: version: 6.8.3 devDependencies: electron: - specifier: ^33.2.0 - version: 33.4.11 + specifier: ^40.0.0 + version: 40.8.2 electron-builder: specifier: ^25.1.8 version: 25.1.8(electron-builder-squirrel-windows@25.1.8) @@ -165,7 +165,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)) + version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)) autoprefixer: specifier: ^10.4.20 version: 10.4.24(postcss@8.5.6) @@ -183,13 +183,13 @@ importers: version: 5.9.3 vite: specifier: ^6.0.3 - version: 6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) + version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) vite-plugin-pwa: specifier: ^1.2.0 - version: 1.2.0(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + version: 1.2.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.33)(jiti@1.21.7)(jsdom@28.1.0)(terser@5.46.0)(tsx@4.21.0) + version: 4.0.18(@types/node@24.12.0)(jiti@1.21.7)(jsdom@28.1.0)(terser@5.46.0)(tsx@4.21.0) workbox-window: specifier: ^7.4.0 version: 7.4.0 @@ -1992,6 +1992,9 @@ packages: '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -2858,8 +2861,8 @@ packages: electron-updater@6.8.3: resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} - electron@33.4.11: - resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} + electron@40.8.2: + resolution: {integrity: sha512-EFgQHG0GBO9glpY/x2v4e7xH5uGuoKOQyXeleljlXZeThbjFsNu0NTUyTCVBOkoKT0F5xCwAOAHcI83b3b8jzA==} engines: {node: '>= 12.20.55'} hasBin: true @@ -4993,6 +4996,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.22.0: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} @@ -7021,6 +7027,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + '@types/plist@3.0.5': dependencies: '@types/node': 20.19.33 @@ -7066,7 +7076,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -7074,7 +7084,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -7087,13 +7097,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))': + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -7980,10 +7990,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@33.4.11: + electron@40.8.2: dependencies: '@electron/get': 2.0.3 - '@types/node': 20.19.33 + '@types/node': 24.12.0 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -10716,6 +10726,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici@7.22.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -10816,18 +10828,18 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-pwa@1.2.0(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) workbox-build: 7.4.0(@types/babel__core@7.20.5) workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0): + vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -10836,16 +10848,16 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 20.19.33 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 1.21.7 terser: 5.46.0 tsx: 4.21.0 - vitest@4.0.18(@types/node@20.19.33)(jiti@1.21.7)(jsdom@28.1.0)(terser@5.46.0)(tsx@4.21.0): + vitest@4.0.18(@types/node@24.12.0)(jiti@1.21.7)(jsdom@28.1.0)(terser@5.46.0)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)) + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -10862,10 +10874,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.33)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.19.33 + '@types/node': 24.12.0 jsdom: 28.1.0 transitivePeerDependencies: - jiti