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:
Jannis Braun
2026-03-16 18:01:40 +01:00
parent 7a86cde67e
commit 46f55643ae
10 changed files with 117 additions and 53 deletions
+3
View File
@@ -17,6 +17,9 @@ protocols:
- backspace - backspace
mac: mac:
category: public.app-category.social-networking category: public.app-category.social-networking
minimumSystemVersion: "12.0"
extendInfo:
NSAudioCaptureUsageDescription: "Backspace needs access to system audio to share sound during screen sharing."
target: target:
- dmg - dmg
- zip - zip
+1 -1
View File
@@ -20,7 +20,7 @@
"electron-updater": "^6.3.0" "electron-updater": "^6.3.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^33.2.0", "electron": "^40.0.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
+14 -11
View File
@@ -432,7 +432,7 @@ function registerIpcHandlers(): void {
}); });
// Screen share picker coordination (used by setDisplayMediaRequestHandler) // 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 // Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings // 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 // Set as default protocol handler
app.setAsDefaultProtocolClient('backspace'); app.setAsDefaultProtocolClient('backspace');
@@ -621,12 +627,12 @@ if (!gotTheLock) {
// Send sources to renderer, wait for user selection // Send sources to renderer, wait for user selection
mainWindow?.webContents.send('screen-share-sources', serialized); mainWindow?.webContents.send('screen-share-sources', serialized);
const sourceId = await new Promise<string | null>((resolve) => { const { sourceId, shareAudio } = await new Promise<{ sourceId: string | null; shareAudio: boolean }>((resolve) => {
ipcMain.once('screen-share-selected', (_event, id: string | null) => { ipcMain.once('screen-share-selected', (_event, id: string | null, wantAudio?: boolean) => {
resolve(id); resolve({ sourceId: id, shareAudio: wantAudio ?? true });
}); });
}); });
console.log('[Main:ScreenShare] User selected:', sourceId); console.log('[Main:ScreenShare] User selected:', sourceId, 'audio:', shareAudio);
if (!sourceId) { if (!sourceId) {
// @ts-ignore — deny the request without crashing // @ts-ignore — deny the request without crashing
@@ -642,12 +648,9 @@ if (!gotTheLock) {
} }
// Provide the selected source — Electron creates the MediaStream // Provide the selected source — Electron creates the MediaStream
// Enable system audio loopback on Windows/Linux (macOS blocks at OS level) // System audio loopback: Windows/Linux native, macOS 13+ via ScreenCaptureKit
if (process.platform === 'darwin') { // Controlled by user's shareAudio preference from the picker UI
callback({ video: selected }); callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
} else {
callback({ video: selected, audio: 'loopback' });
}
} catch (err) { } catch (err) {
console.error('[Main:ScreenShare] Handler error:', err); console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing // @ts-ignore — deny the request without crashing
+2 -2
View File
@@ -54,8 +54,8 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => { onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources)); ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
}, },
selectScreenSource: (sourceId: string | null) => { selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId); ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
}, },
// Instance URL management // Instance URL management
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { getElectronAPI } from '../../platform/platform'; import { getElectronAPI } from '../../platform/platform';
import { useVoiceStore } from '../../stores/voiceStore';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Zustand micro-store — bridges the event-driven API to React state // 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 // Close helper — sends selection back to main process
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function closePicker(sourceId: string | null) { function closePicker(sourceId: string | null, shareAudio?: boolean) {
const api = getElectronAPI(); const api = getElectronAPI();
if (api) api.selectScreenSource(sourceId); if (api) api.selectScreenSource(sourceId, shareAudio);
useScreenPickerStore.setState({ isOpen: false, sources: [] }); useScreenPickerStore.setState({ isOpen: false, sources: [] });
} }
@@ -37,6 +38,8 @@ export function ScreenSharePicker() {
const [activeTab, setActiveTab] = useState<Tab>('screens'); const [activeTab, setActiveTab] = useState<Tab>('screens');
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState(''); 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) // Register listener for sources from main process (once on mount)
useEffect(() => { useEffect(() => {
@@ -93,8 +96,6 @@ export function ScreenSharePicker() {
return wins.filter((w) => w.name.toLowerCase().includes(q)); return wins.filter((w) => w.name.toLowerCase().includes(q));
}, [sources, search]); }, [sources, search]);
const isMac = getElectronAPI()?.platform === 'darwin';
if (!isOpen) return null; if (!isOpen) return null;
const activeSources = activeTab === 'screens' ? screens : windows; const activeSources = activeTab === 'screens' ? screens : windows;
@@ -168,7 +169,7 @@ export function ScreenSharePicker() {
source={source} source={source}
selected={selectedId === source.id} selected={selectedId === source.id}
onClick={() => setSelectedId(source.id)} onClick={() => setSelectedId(source.id)}
onDoubleClick={() => closePicker(source.id)} onDoubleClick={() => closePicker(source.id, shareAudio)}
/> />
))} ))}
</div> </div>
@@ -177,11 +178,22 @@ export function ScreenSharePicker() {
{/* Footer */} {/* Footer */}
<div className="flex-shrink-0 flex flex-col items-center px-5 pt-2 pb-4"> <div className="flex-shrink-0 flex flex-col items-center px-5 pt-2 pb-4">
{isMac ? ( <div className="flex flex-col items-center gap-1 mb-2">
<div className="text-[11px] text-txt-tertiary mb-2">System audio is not available on macOS</div> <label className="flex items-center gap-2 cursor-pointer select-none">
) : ( <input
<div className="text-[11px] text-txt-tertiary mb-2">System audio will be shared</div> 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"> <div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3">
<button <button
onClick={() => closePicker(null)} onClick={() => closePicker(null)}
@@ -190,7 +202,7 @@ export function ScreenSharePicker() {
Cancel Cancel
</button> </button>
<button <button
onClick={() => closePicker(selectedId)} onClick={() => closePicker(selectedId, shareAudio)}
disabled={!selectedId} 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" 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 { useSettingsStore } from '../../stores/settingsStore';
import { buildScreenShareOptions } from '../../utils/screenShare'; import { buildScreenShareOptions } from '../../utils/screenShare';
import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { Toggle } from '../ui/Toggle';
import { isElectron } from '../../platform/platform';
interface ScreenShareSettingsPopoverProps { interface ScreenShareSettingsPopoverProps {
open: boolean; open: boolean;
@@ -217,6 +219,26 @@ export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenS
</span> </span>
</div> </div>
</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> </div>
{/* Footer — computed stats */} {/* Footer — computed stats */}
+1 -1
View File
@@ -36,7 +36,7 @@ interface BackspaceElectronAPI {
// Screen share picker coordination // Screen share picker coordination
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void; onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
selectScreenSource: (sourceId: string | null) => void; selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
// Instance URL management // Instance URL management
getInstanceUrl: () => Promise<string | null>; getInstanceUrl: () => Promise<string | null>;
+8 -2
View File
@@ -10,6 +10,7 @@ export interface ScreenShareConfig {
fps: 60 | 45 | 30; fps: 60 | 45 | 30;
mode: 'gaming' | 'text'; mode: 'gaming' | 'text';
customBitrateKbps: number | null; customBitrateKbps: number | null;
shareAudio: boolean;
} }
interface VoiceState { interface VoiceState {
@@ -134,7 +135,7 @@ export const useVoiceStore = create<VoiceState>()(
inputDeviceId: 'default', inputDeviceId: 'default',
outputDeviceId: 'default', outputDeviceId: 'default',
focusedParticipantId: null, 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(), participantVolumes: new Map(),
setParticipantVolume: (userId, volume) => { setParticipantVolume: (userId, volume) => {
set((state) => { set((state) => {
@@ -527,7 +528,7 @@ export const useVoiceStore = create<VoiceState>()(
}), }),
{ {
name: 'backspace-voice-settings', name: 'backspace-voice-settings',
version: 9, version: 10,
migrate: (persistedState: any, version: number) => { migrate: (persistedState: any, version: number) => {
if (version === 0) { if (version === 0) {
persistedState.streamAttenuationEnabled = false; persistedState.streamAttenuationEnabled = false;
@@ -566,6 +567,11 @@ export const useVoiceStore = create<VoiceState>()(
if (version < 9) { if (version < 9) {
delete persistedState.currentVoiceChannelId; delete persistedState.currentVoiceChannelId;
} }
if (version < 10) {
if (persistedState.screenShareConfig) {
persistedState.screenShareConfig.shareAudio = true;
}
}
return persistedState; return persistedState;
}, },
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
+9 -3
View File
@@ -131,16 +131,22 @@ export async function applyOverdrive(
export async function startScreenShare(room: Room): Promise<boolean> { export async function startScreenShare(room: Room): Promise<boolean> {
console.log('[SS] startScreenShare called, room state:', room.state); 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 { try {
const track = await room.localParticipant.setScreenShareEnabled(true, { 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, echoCancellation: false,
noiseSuppression: false, noiseSuppression: false,
autoGainControl: false, autoGainControl: false,
channelCount: 2, channelCount: 2,
}, } : false,
resolution: { width: opts.capture.width, height: opts.capture.height }, resolution: { width: opts.capture.width, height: opts.capture.height },
// @ts-ignore — LiveKit accepts frameRate at capture level // @ts-ignore — LiveKit accepts frameRate at capture level
frameRate: opts.capture.frameRate, frameRate: opts.capture.frameRate,
+34 -22
View File
@@ -15,8 +15,8 @@ importers:
version: 6.8.3 version: 6.8.3
devDependencies: devDependencies:
electron: electron:
specifier: ^33.2.0 specifier: ^40.0.0
version: 33.4.11 version: 40.8.2
electron-builder: electron-builder:
specifier: ^25.1.8 specifier: ^25.1.8
version: 25.1.8(electron-builder-squirrel-windows@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) version: 18.3.7(@types/react@18.3.28)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.4 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: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.24(postcss@8.5.6) version: 10.4.24(postcss@8.5.6)
@@ -183,13 +183,13 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^6.0.3 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: vite-plugin-pwa:
specifier: ^1.2.0 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: vitest:
specifier: ^4.0.18 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: workbox-window:
specifier: ^7.4.0 specifier: ^7.4.0
version: 7.4.0 version: 7.4.0
@@ -1992,6 +1992,9 @@ packages:
'@types/node@20.19.33': '@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
'@types/plist@3.0.5': '@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
@@ -2858,8 +2861,8 @@ packages:
electron-updater@6.8.3: electron-updater@6.8.3:
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
electron@33.4.11: electron@40.8.2:
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} resolution: {integrity: sha512-EFgQHG0GBO9glpY/x2v4e7xH5uGuoKOQyXeleljlXZeThbjFsNu0NTUyTCVBOkoKT0F5xCwAOAHcI83b3b8jzA==}
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
@@ -4993,6 +4996,9 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.22.0: undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
@@ -7021,6 +7027,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
'@types/plist@3.0.5': '@types/plist@3.0.5':
dependencies: dependencies:
'@types/node': 20.19.33 '@types/node': 20.19.33
@@ -7066,7 +7076,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@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 '@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -7087,13 +7097,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.0.3 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: dependencies:
'@vitest/spy': 4.0.18 '@vitest/spy': 4.0.18
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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': '@vitest/pretty-format@4.0.18':
dependencies: dependencies:
@@ -7980,10 +7990,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
electron@33.4.11: electron@40.8.2:
dependencies: dependencies:
'@electron/get': 2.0.3 '@electron/get': 2.0.3
'@types/node': 20.19.33 '@types/node': 24.12.0
extract-zip: 2.0.1 extract-zip: 2.0.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -10716,6 +10726,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.22.0: {} undici@7.22.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
@@ -10816,18 +10828,18 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.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: dependencies:
debug: 4.4.3 debug: 4.4.3
pretty-bytes: 6.1.1 pretty-bytes: 6.1.1
tinyglobby: 0.2.15 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-build: 7.4.0(@types/babel__core@7.20.5)
workbox-window: 7.4.0 workbox-window: 7.4.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -10836,16 +10848,16 @@ snapshots:
rollup: 4.57.1 rollup: 4.57.1
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 20.19.33 '@types/node': 24.12.0
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 1.21.7 jiti: 1.21.7
terser: 5.46.0 terser: 5.46.0
tsx: 4.21.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: dependencies:
'@vitest/expect': 4.0.18 '@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/pretty-format': 4.0.18
'@vitest/runner': 4.0.18 '@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18 '@vitest/snapshot': 4.0.18
@@ -10862,10 +10874,10 @@ snapshots:
tinyexec: 1.0.2 tinyexec: 1.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 20.19.33 '@types/node': 24.12.0
jsdom: 28.1.0 jsdom: 28.1.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti