Files
backspace/packages/desktop/src/main.ts
T
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00

1192 lines
43 KiB
TypeScript

import {
app,
BrowserWindow,
Tray,
Menu,
Notification,
nativeImage,
ipcMain,
shell,
screen,
session,
desktopCapturer,
} from 'electron';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector';
import { KeybindManager } from './keybindManager';
import { deriveStartMinimizedFromArgs, parseExecPathFromDesktopFile, shouldReapplyAppImage } from './autoLaunch';
import {
loadInstanceUrl,
saveInstanceUrl,
clearInstanceUrl,
getPickerPath,
} from './instanceUrl';
import {
recoveryStore,
attachRecoveryHandlers,
extractErrorCode,
setAutoUpdater,
setMainWindow,
setOnQuitRequested,
handleRendererReady,
handleRecoveryAction,
isValidRecoveryAction,
buildTrayMenuTemplate,
buildAppMenuTemplate,
type RecoveryState,
} from './recovery';
import { migrateUserData } from './userDataMigration';
// Override Electron's package.json-derived app name so userData lives at
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
// package name. Must run before any app.getPath('userData') consumer.
app.setName('Backspace');
// One-time migration from the historical scoped path. After the move the old
// folder is gone, so subsequent launches hit the old-missing no-op branch.
{
const appDataDir = app.getPath('appData');
const oldParent = path.join(appDataDir, '@backspace');
const result = migrateUserData({
oldDir: path.join(oldParent, 'desktop'),
newDir: path.join(appDataDir, 'Backspace'),
oldParent,
});
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from}${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
}
}
let mainWindow: BrowserWindow | null = null;
const keybindManager = new KeybindManager();
let tray: Tray | null = null;
let isQuitting = false;
let pendingDeepLink: string | null = null;
const knownInstanceOrigins = new Set<string>();
// ─── AGPL-3.0 § 13 source offer ─────────────────────────────────────────────
// Upstream fallback for the "Source code" menu items and the About panel.
// Used when the connected instance can't be reached or advertises no source URL.
const UPSTREAM_SOURCE_URL = 'https://github.com/TheZwiss/backspace';
/**
* Resolve the Corresponding Source URL for the instance the desktop app is
* pointed at, honouring an operator's modified fork via GET /api/instance/info.
* Falls back to the upstream repo when no instance is loaded or the probe fails.
*/
async function resolveSourceUrl(): Promise<string> {
let base: string | null = process.env.BACKSPACE_URL ?? loadInstanceUrl();
if (!base && mainWindow && !mainWindow.isDestroyed()) {
const current = mainWindow.webContents.getURL();
if (current.startsWith('http://') || current.startsWith('https://')) base = current;
}
if (!base) return UPSTREAM_SOURCE_URL;
try {
const origin = new URL(base).origin;
const res = await fetch(`${origin}/api/instance/info`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return UPSTREAM_SOURCE_URL;
const info = (await res.json()) as { sourceCodeUrl?: unknown };
if (typeof info.sourceCodeUrl === 'string' && /^https?:\/\//i.test(info.sourceCodeUrl)) {
return info.sourceCodeUrl;
}
} catch {
// Unreachable / malformed — fall back to upstream.
}
return UPSTREAM_SOURCE_URL;
}
/** Open the resolved source URL externally; upstream fallback on any failure. */
function openSourceCode(): void {
resolveSourceUrl()
.then((url) => shell.openExternal(url))
.catch(() => { void shell.openExternal(UPSTREAM_SOURCE_URL); });
}
// ─── Window State Persistence ───────────────────────────────────────────────
interface WindowState {
width: number;
height: number;
x?: number;
y?: number;
isMaximized: boolean;
}
const DEFAULT_WINDOW_STATE: WindowState = {
width: 1280,
height: 800,
isMaximized: false,
};
function getWindowStatePath(): string {
return path.join(app.getPath('userData'), 'window-state.json');
}
function loadWindowState(): WindowState {
try {
const raw = fs.readFileSync(getWindowStatePath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<WindowState>;
return {
width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WINDOW_STATE.width,
height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_WINDOW_STATE.height,
x: typeof parsed.x === 'number' ? parsed.x : undefined,
y: typeof parsed.y === 'number' ? parsed.y : undefined,
isMaximized: typeof parsed.isMaximized === 'boolean' ? parsed.isMaximized : false,
};
} catch {
return { ...DEFAULT_WINDOW_STATE };
}
}
function validateWindowBounds(state: WindowState): WindowState {
if (state.x === undefined || state.y === undefined) return state;
const bounds = { x: state.x, y: state.y, width: state.width, height: state.height };
const display = screen.getDisplayMatching(bounds);
const { x, y, width, height } = display.workArea;
// Check if window is at least partially visible on the display
const visible =
bounds.x + bounds.width > x &&
bounds.x < x + width &&
bounds.y + bounds.height > y &&
bounds.y < y + height;
if (!visible) {
// Strip position — let Electron auto-center
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
}
return state;
}
function saveWindowState(win: BrowserWindow): void {
try {
const isMaximized = win.isMaximized();
// Use the bounds from before maximize, to restore the un-maximized size
const bounds = isMaximized ? win.getNormalBounds() : win.getBounds();
const state: WindowState = {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
isMaximized,
};
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
} catch {
// Non-critical — silently ignore write failures
}
}
// ─── Auto-Launch Settings ────────────────────────────────────────────────────
interface AutoLaunchSettings {
openAtLogin: boolean;
startMinimized: boolean;
}
const DEFAULT_AUTO_LAUNCH: AutoLaunchSettings = {
openAtLogin: false,
startMinimized: true,
};
function getAutoLaunchSettingsPath(): string {
return path.join(app.getPath('userData'), 'auto-launch.json');
}
function loadAutoLaunchSettings(): AutoLaunchSettings {
try {
const raw = fs.readFileSync(getAutoLaunchSettingsPath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<AutoLaunchSettings>;
return {
openAtLogin: typeof parsed.openAtLogin === 'boolean' ? parsed.openAtLogin : DEFAULT_AUTO_LAUNCH.openAtLogin,
startMinimized: typeof parsed.startMinimized === 'boolean' ? parsed.startMinimized : DEFAULT_AUTO_LAUNCH.startMinimized,
};
} catch {
return { ...DEFAULT_AUTO_LAUNCH };
}
}
function saveAutoLaunchSettings(settings: AutoLaunchSettings): void {
fs.writeFileSync(getAutoLaunchSettingsPath(), JSON.stringify(settings));
}
function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean): void {
if (process.platform === 'darwin') {
// macOS: pass `args` in addition to `openAsHidden` so the renderer/main can
// detect a hidden launch via `process.argv.includes('--hidden')` on macOS 13+
// where the new ServiceManagement-backed implementation may not honour
// `wasOpenedAsHidden`. Both detection paths now work (defence in depth).
app.setLoginItemSettings({
openAtLogin,
openAsHidden: startMinimized,
args: startMinimized ? ['--hidden'] : [],
});
} else if (process.platform === 'win32') {
// Windows: `enabled` is REQUIRED to undo a Task-Manager-side disable.
// Without it, re-enabling our toggle leaves the StartupApproved\Run
// "disabled" marker in place and the user's Run entry still won't fire.
// We pass `enabled: openAtLogin` so toggling ON re-enables, toggling OFF
// removes the entry entirely (deletion supersedes the disable marker).
// `path` and `args` are passed explicitly so subsequent get() calls can
// match the right launchItems[] entry.
app.setLoginItemSettings({
openAtLogin,
enabled: openAtLogin,
path: process.execPath,
args: startMinimized ? ['--hidden'] : [],
name: 'Backspace',
});
} else {
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
// - We pass an explicit `name: 'backspace'` so the filename is deterministic
// across deb/AppImage installs and Electron versions.
// - For AppImage, $APPIMAGE points to the (possibly newly-updated) AppImage
// path; pass it as `path` so the autostart entry tracks updates.
// Electron's TypeScript types don't list `path`/`args`/`name` on Linux,
// but the runtime accepts them.
const opts: Record<string, unknown> = {
openAtLogin,
name: 'backspace',
};
if (process.env.APPIMAGE) {
opts.path = process.env.APPIMAGE;
}
if (startMinimized) {
opts.args = ['--hidden'];
}
app.setLoginItemSettings(opts as Electron.Settings);
}
}
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage {
const size = 16;
const canvas = Buffer.alloc(size * size * 4);
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
if (dist <= r) {
// NativeImage raw buffer uses BGRA on most platforms
canvas[idx] = 0xf2; // B (blurple #5865f2)
canvas[idx + 1] = 0x65; // G
canvas[idx + 2] = 0x58; // R
canvas[idx + 3] = 0xff; // A
} else {
canvas[idx] = 0;
canvas[idx + 1] = 0;
canvas[idx + 2] = 0;
canvas[idx + 3] = 0;
}
}
}
return nativeImage.createFromBuffer(canvas, { width: size, height: size });
}
function loadTrayIcon(): Electron.NativeImage {
const resourcesDir = path.join(__dirname, '..', 'resources');
try {
if (process.platform === 'darwin') {
// macOS template image: Electron auto-resolves @2x from the base path.
// Template images adapt to light/dark menu bar automatically.
const templatePath = path.join(resourcesDir, 'tray-iconTemplate.png');
const icon = nativeImage.createFromPath(templatePath);
if (!icon.isEmpty()) {
icon.setTemplateImage(true);
return icon;
}
} else if (process.platform === 'win32') {
// Windows: multi-size .ico — Windows + Electron auto-pick best size for current DPI.
const icoPath = path.join(resourcesDir, 'tray-icon.ico');
const icon = nativeImage.createFromPath(icoPath);
if (!icon.isEmpty()) {
return icon;
}
} else {
// Linux: single 22x22 PNG (AppIndicator / StatusNotifier convention).
// No runtime resize — the source is already at correct tray size.
const iconPath = path.join(resourcesDir, 'tray-icon.png');
const icon = nativeImage.createFromPath(iconPath);
if (!icon.isEmpty()) {
return icon;
}
}
} catch {
// Fall through to generated icon
}
return generateFallbackTrayIcon();
}
// ─── Window & Tray Creation ─────────────────────────────────────────────────
function createWindow(): void {
const savedState = validateWindowBounds(loadWindowState());
mainWindow = new BrowserWindow({
width: savedState.width,
height: savedState.height,
...(savedState.x !== undefined && savedState.y !== undefined
? { x: savedState.x, y: savedState.y }
: {}),
minWidth: 940,
minHeight: 500,
title: 'Backspace',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(process.platform !== 'darwin' ? {
titleBarOverlay: {
color: '#0b0b10',
symbolColor: '#d8d8de',
height: 32,
},
} : {}),
backgroundColor: '#313338',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
setMainWindow(mainWindow);
attachRecoveryHandlers(mainWindow);
keybindManager.setWindow(mainWindow);
if (savedState.isMaximized) {
mainWindow.maximize();
}
// URL resolution priority:
// 1. BACKSPACE_URL env var (managed deployments)
// 2. Saved instance URL from picker
// 3. No URL → show instance picker
const envUrl = process.env.BACKSPACE_URL;
if (envUrl) {
mainWindow.loadURL(envUrl);
} else {
const savedUrl = loadInstanceUrl();
if (savedUrl) {
mainWindow.loadURL(savedUrl);
} else {
mainWindow.loadFile(getPickerPath());
}
}
mainWindow.once('ready-to-show', () => {
// Hidden-launch detection. We pass `args: ['--hidden']` on all three platforms
// (see applyLoginItemSettings), so the argv check is the primary signal. On
// macOS we also honour `wasOpenedAsHidden` as a fallback for the legacy
// openAsHidden path on macOS < 13.
const launchedHidden =
process.argv.includes('--hidden') ||
(process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAsHidden);
if (!launchedHidden) {
mainWindow?.show();
}
// Send any pending deep link that launched the app
if (pendingDeepLink && mainWindow) {
mainWindow.webContents.send('deep-link', pendingDeepLink);
pendingDeepLink = null;
}
});
// Window state persistence — debounced save on resize/move
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
saveWindowState(mainWindow);
}
}, 300);
};
mainWindow.on('resize', debouncedSave);
mainWindow.on('move', debouncedSave);
mainWindow.on('close', (event) => {
// Save state before close
if (mainWindow && !mainWindow.isDestroyed()) {
saveWindowState(mainWindow);
}
if (!isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
setMainWindow(null);
mainWindow = null;
// Clear recovery state so macOS dock-activate (which calls createWindow again)
// gets a clean slate. Without this, the new window inherits inRecoveryMode=true
// and the recovery surface is silently lost.
recoveryStore.markRecoveryExited();
recoveryStore.update({ mode: 'normal', reason: null });
});
// Window focus IPC for notification suppression
mainWindow.on('focus', () => {
mainWindow?.webContents.send('window-focus-changed', true);
});
mainWindow.on('blur', () => {
mainWindow?.webContents.send('window-focus-changed', false);
});
// Intercept own-instance /join/* URLs so they open in-app instead of the
// system browser. Other URLs continue to open externally.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const u = new URL(url);
if (
(u.protocol === 'http:' || u.protocol === 'https:') &&
knownInstanceOrigins.has(u.origin) &&
u.pathname.startsWith('/join/')
) {
mainWindow?.webContents.send('open-internal-route', u.pathname + u.search);
return { action: 'deny' };
}
} catch { /* malformed URL — fall through to external */ }
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
}
function createTray(): void {
const icon = loadTrayIcon();
tray = new Tray(icon);
tray.setToolTip('Backspace');
// Context menu is set by the recoveryStore subscriber in app.whenReady,
// which keeps the menu in sync with update/recovery state.
tray.on('click', () => {
if (mainWindow?.isVisible()) {
mainWindow.hide();
} else {
mainWindow?.show();
mainWindow?.focus();
}
});
}
// ─── Notifications ──────────────────────────────────────────────────────────
function showNotification(title: string, body: string, onClick?: () => void): void {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body, silent: false });
notification.on('click', onClick ?? (() => {
mainWindow?.show();
mainWindow?.focus();
}));
notification.show();
}
// ─── IPC Handlers ───────────────────────────────────────────────────────────
function registerIpcHandlers(): void {
ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => {
showNotification(data.title, data.body);
});
ipcMain.on('set-badge-count', (_event, count: number) => {
if (app.setBadgeCount) {
app.setBadgeCount(count);
}
});
ipcMain.on('minimize-window', () => {
mainWindow?.minimize();
});
ipcMain.on('maximize-window', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on('close-window', () => {
mainWindow?.close();
});
// Connected-origins sync — renderer pushes the current set of instance origins
// so setWindowOpenHandler can route /join/* URLs in-app synchronously.
ipcMain.on('set-connected-origins', (_evt, origins: unknown) => {
if (!Array.isArray(origins)) return;
knownInstanceOrigins.clear();
for (const o of origins) {
if (typeof o === 'string' && o.length > 0) knownInstanceOrigins.add(o);
}
});
// Instance URL management
ipcMain.handle('get-instance-url', () => loadInstanceUrl());
ipcMain.handle('set-instance-url', (_event, url: string) => {
saveInstanceUrl(url);
if (mainWindow) {
mainWindow.loadURL(url);
// Force Electron to re-evaluate drag regions after navigation
mainWindow.webContents.once('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
mainWindow.setSize(bounds.width + 1, bounds.height);
mainWindow.setSize(bounds.width, bounds.height);
}
});
}
});
ipcMain.handle('clear-instance-url', () => {
clearInstanceUrl();
if (mainWindow) {
mainWindow.loadFile(getPickerPath());
// Force Electron to re-evaluate drag regions after navigation
mainWindow.webContents.once('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
mainWindow.setSize(bounds.width + 1, bounds.height);
mainWindow.setSize(bounds.width, bounds.height);
}
});
}
});
// Auto-update IPC
ipcMain.on('install-update', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall();
} catch {
// Auto-updater not available
}
});
ipcMain.on('check-for-updates', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.checkForUpdates().catch(() => {});
} catch {
// Auto-updater not available
}
});
ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
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-launch settings
ipcMain.handle('get-auto-launch-settings', (): { openAtLogin: boolean; startMinimized: boolean } => {
if (process.platform === 'win32') {
// Pass path/args so getLoginItemSettings can find the matching launchItems[] entry.
// We can't know in advance whether the user's saved choice was minimized or not,
// so we query without args and inspect launchItems[] directly. Use
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
// When the Run entry exists, its args are the source of truth for startMinimized.
// When the entry is absent (autostart is off), there is no OS state to read, so we
// fall back to the disk cache — this preserves the user's preference across an
// off/on cycle so re-enabling restores their previous startMinimized choice.
const startMinimized = ownEntry
? deriveStartMinimizedFromArgs(ownEntry.args)
: loadAutoLaunchSettings().startMinimized;
return {
openAtLogin: osState.executableWillLaunchAtLogin ?? false,
startMinimized,
};
}
// macOS and Linux: getLoginItemSettings doesn't expose args, so startMinimized
// is disk-cached. Rationale: parsing freedesktop Exec= lines on Linux is fragile
// (quoting, escaping, third-party flags) and the out-of-band edit case is rare;
// macOS has no introspection. openAtLogin is OS-authoritative on both platforms.
const saved = loadAutoLaunchSettings();
const osState = app.getLoginItemSettings();
return {
openAtLogin: osState.openAtLogin,
startMinimized: saved.startMinimized,
};
});
ipcMain.handle('set-auto-launch-settings', (_event, settings: { openAtLogin?: boolean; startMinimized?: boolean }) => {
// Read current truth from the OS (not from disk) so a partial update preserves
// whatever the user (or Task Manager / System Settings) most recently set.
let currentOpenAtLogin: boolean;
let currentStartMinimized: boolean;
if (process.platform === 'win32') {
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
// cache so a partial update that re-enables openAtLogin restores the user's
// previously-saved startMinimized choice rather than resetting to false.
currentStartMinimized = ownEntry
? deriveStartMinimizedFromArgs(ownEntry.args)
: loadAutoLaunchSettings().startMinimized;
} else {
const osState = app.getLoginItemSettings();
const saved = loadAutoLaunchSettings();
currentOpenAtLogin = osState.openAtLogin;
currentStartMinimized = saved.startMinimized;
}
const newOpenAtLogin = settings.openAtLogin ?? currentOpenAtLogin;
const newStartMinimized = settings.startMinimized ?? currentStartMinimized;
applyLoginItemSettings(newOpenAtLogin, newStartMinimized);
// Persist startMinimized as a disk cache for the macOS / Linux read path.
// We also save openAtLogin for forward compatibility / debugging, but it is
// never the source of truth on read.
saveAutoLaunchSettings({
openAtLogin: newOpenAtLogin,
startMinimized: newStartMinimized,
});
return { openAtLogin: newOpenAtLogin, startMinimized: newStartMinimized };
});
// Keybinds
ipcMain.on('keybinds-sync', (_event, keybinds) => {
keybindManager.updateKeybinds(keybinds);
});
ipcMain.handle('check-accessibility', () => {
return keybindManager.checkAccessibility();
});
// Recovery / boot-stall protocol
ipcMain.on('renderer-ready', () => {
handleRendererReady();
});
ipcMain.on('recovery-action', (_e, action: unknown) => {
if (!isValidRecoveryAction(action)) {
console.warn('[recovery] ignored unknown action:', action);
return;
}
handleRecoveryAction(action);
});
ipcMain.handle('get-recovery-state', () => recoveryStore.get());
}
// ─── Auto-Update ────────────────────────────────────────────────────────────
function initAutoUpdater(): void {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
setAutoUpdater(autoUpdater);
let updateConfirmed = false;
autoUpdater.on('checking-for-update', () => {
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
updateConfirmed = false;
});
autoUpdater.on('update-available', (info: { version: string }) => {
updateConfirmed = true;
recoveryStore.update({ updateState: 'downloading', updateVersion: info.version });
mainWindow?.webContents.send('update-available', { version: info.version });
});
autoUpdater.on('update-not-available', () => {
updateConfirmed = false;
recoveryStore.update({ updateState: 'idle', lastCheckResult: 'up-to-date' });
setTimeout(() => {
if (recoveryStore.get().lastCheckResult === 'up-to-date') {
recoveryStore.update({ lastCheckResult: null });
}
}, 5_000);
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
const version = info.version.slice(0, 32);
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
mainWindow?.webContents.send('update-downloaded', { version });
// Symmetric focus-based suppression: if the user is looking at the
// window, the in-app banner (normal mode) or recovery Restart button
// (recovery mode) is visible — no need to also fire a native toast.
if (!mainWindow?.isFocused()) {
showNotification(
'Backspace update ready',
`Click to restart and install version ${version}.`,
() => autoUpdater.quitAndInstall(),
);
}
});
autoUpdater.on('error', (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
const code = extractErrorCode(err);
recoveryStore.update({
updateState: 'error',
lastUpdateError: { message, code, at: Date.now() },
lastCheckResult: 'failed',
});
setTimeout(() => {
if (recoveryStore.get().lastCheckResult === 'failed') {
recoveryStore.update({ lastCheckResult: null });
}
}, 5_000);
// Existing behavior preserved: only push renderer error IPC after
// confirmed update. Check-phase errors stay silent.
if (updateConfirmed) {
mainWindow?.webContents.send('update-error', {
message,
releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest',
});
}
});
// Initial check with 10s delay (existing behavior)
setTimeout(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// Periodic check every 4 hours (existing behavior)
setInterval(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
} catch {
// Auto-updater unavailable — recovery surface still functions, just
// without update affordances. autoUpdaterRef stays null; recovery action
// handlers no-op on update-related actions.
}
}
// ─── Deep Linking ───────────────────────────────────────────────────────────
function handleDeepLink(url: string): void {
if (!url.startsWith('backspace://')) return;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
mainWindow.show();
mainWindow.focus();
} else {
// App not ready yet — store for later
pendingDeepLink = url;
}
}
// 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');
// Chromium ships PulseAudio loopback for screen-share behind a feature flag.
// Without it, returning `audio: 'loopback'` from setDisplayMediaRequestHandler
// fails the whole getDisplayMedia request — screen share never starts when the
// user has "Share system audio" enabled.
app.commandLine.appendSwitch('enable-features', 'PulseaudioLoopbackForScreenShare');
}
// Windows: AppUserModelId so toast notifications attribute correctly to Backspace
// (without this, recovery / update notifications appear under "electron.exe").
if (process.platform === 'win32') {
app.setAppUserModelId('com.backspace.desktop');
}
/**
* Request a clean app quit. Sets the isQuitting flag so the window 'close'
* handler doesn't intercept (which would just hide the window) and then
* triggers app.quit(). Exported so the recovery module can request a quit
* via setOnQuitRequested without reaching into private state.
*/
export function requestQuit(): void {
isQuitting = true;
app.quit();
}
// Set as default protocol handler
app.setAsDefaultProtocolClient('backspace');
// macOS: open-url event
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
// Windows/Linux: single instance lock — deep links come as second-instance args
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (_event, commandLine) => {
// Find the deep link URL in the command line args
const deepLinkArg = commandLine.find((arg) => arg.startsWith('backspace://'));
if (deepLinkArg) {
handleDeepLink(deepLinkArg);
}
// Focus the existing window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// ─── App Lifecycle ──────────────────────────────────────────────────────────
app.whenReady().then(async () => {
// Win/Linux: frameless window has no menu bar, but we still need an
// application menu so keyboard accelerators (Ctrl+C/V/X/Z/A) work.
// The macOS app menu is owned by the recoveryStore subscriber below
// (so it can re-render with current update/recovery state).
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(Menu.buildFromTemplate([
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
]));
}
// Purge ALL stale caches so Electron always loads fresh code on launch
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
await session.defaultSession.clearCache();
// Intercept getDisplayMedia() — show custom picker in renderer.
// Audio loopback controlled by user's shareAudio toggle.
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 support varies:
// - Windows: native (Chromium default).
// - macOS 13+: CoreAudio Tap; requires NSAudioCaptureUsageDescription
// in Info.plist (electron-builder injects it via mac.extendInfo).
// - Linux: PulseAudio loopback, gated behind the
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user.
// Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) {
console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing
callback();
}
});
registerIpcHandlers();
// Wire the recovery module's quit callback to the local requestQuit BEFORE
// createWindow so a synchronous did-fail-load on first load finds a wired
// Quit handler. requestQuit is a function declaration and is hoisted.
setOnQuitRequested(() => requestQuit());
createWindow();
createTray();
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
app.setAboutPanelOptions({
applicationName: 'Backspace',
applicationVersion: app.getVersion(),
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
website: UPSTREAM_SOURCE_URL,
});
// Tray + macOS app-menu actions. Defined once so the subscriber and the
// initial-fire share one implementation (no drift on future menu changes).
const trayActions = {
onShow: () => { mainWindow?.show(); mainWindow?.focus(); },
onHide: () => mainWindow?.hide(),
// Delegate to handleRecoveryAction so both the tray and the recovery
// surface share one implementation path (avoids drift and ensures
// recovery state is always cleared on a Change Instance action).
onChangeInstance: () => handleRecoveryAction('change-instance'),
onCheckForUpdates: () => handleRecoveryAction('check-update'),
onRestartToInstall: () => handleRecoveryAction('install-update'),
onOpenSource: () => openSourceCode(),
onQuit: () => requestQuit(),
};
const applyMenusForState = (state: RecoveryState): void => {
if (tray) {
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(state, trayActions)));
}
if (process.platform === 'darwin') {
Menu.setApplicationMenu(Menu.buildFromTemplate(buildAppMenuTemplate(app.name, state, trayActions)));
}
// Mode-gated push to renderer (recovery.html subscribes to this).
if (state.mode === 'recovery' && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('recovery-state-changed', state);
}
};
recoveryStore.subscribe(applyMenusForState);
applyMenusForState(recoveryStore.get()); // initial fire — subscribers don't auto-fire on subscribe
initAutoUpdater();
// ─── Activity Detection ────────────────────────────────────────────────
startActivityDetection((activity) => {
mainWindow?.webContents.send('activity-detected', activity);
});
ipcMain.handle('get-current-activity', () => getCurrentActivity());
// Linux/AppImage path-refresh ONLY. On Windows and macOS the OS is the source
// of truth for openAtLogin (Task 4) and we must not override user changes made
// via Task Manager / System Settings by re-applying disk state here.
//
// For an AppImage install whose path changed (e.g. the user replaced the file
// after an update), the autostart .desktop file's Exec= line points at the old
// path. Re-apply only when $APPIMAGE differs from the recorded Exec= path.
if (process.platform === 'linux' && process.env.APPIMAGE) {
try {
const desktopFilePath = path.join(
os.homedir(),
'.config',
'autostart',
'backspace.desktop',
);
let recordedExecPath: string | null = null;
try {
const content = fs.readFileSync(desktopFilePath, 'utf-8');
recordedExecPath = parseExecPathFromDesktopFile(content);
} catch {
// No autostart entry exists. recordedExecPath stays null and shouldReapplyAppImage
// will return false — a missing file is treated as user-disabled (out-of-band edit),
// not a stale-path-needs-refresh signal.
}
const saved = loadAutoLaunchSettings();
if (saved.openAtLogin && shouldReapplyAppImage(process.env.APPIMAGE, recordedExecPath)) {
applyLoginItemSettings(saved.openAtLogin, saved.startMinimized);
}
} catch (err) {
console.error('[autoLaunch] AppImage path-refresh check failed:', err);
}
}
// Check if the app was launched with a deep link (Windows/Linux)
const launchArg = process.argv.find((arg) => arg.startsWith('backspace://'));
if (launchArg) {
pendingDeepLink = launchArg;
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
} else {
mainWindow.show();
}
});
app.on('before-quit', () => {
isQuitting = true;
stopActivityDetection();
keybindManager.stop();
});
}