Files

52 KiB
Raw Permalink Blame History

Desktop & Electron System

Source files:

  • packages/desktop/src/main.ts — Main process: window management, tray, IPC handlers, auto-update, deep links, app lifecycle
  • packages/desktop/src/preload.ts — Context bridge: exposes window.backspace API to renderer
  • packages/desktop/src/activityDetector.ts — Process polling, game dictionary loading/sync, activity change detection
  • packages/desktop/src/keybindManager.ts — Global keybinds via uIOhook, native keycode mapping, press/release tracking
  • packages/web/src/stores/keybindStore.ts — Client-side keybind persistence (Zustand + localStorage)
  • packages/web/src/hooks/useKeybinds.ts — Keybind dispatch: Electron IPC bridge + web capture-phase fallback
  • packages/web/src/platform/electron.d.ts — TypeScript declarations for window.backspace
  • packages/web/src/platform/platform.tsisElectron() / isElectronMac() / getElectronAPI() helpers
  • packages/desktop/electron-builder.yml — Build config, protocol registration, afterPack hook
  • packages/desktop/scripts/afterPack.js — Cross-platform native module cleanup (critical for builds)
  • packages/desktop/resources/games.json — Bundled game dictionary seed (versioned)

Architecture Overview

The desktop app wraps the Backspace web client in Electron with:

  • Main process (main.ts): Window lifecycle, tray icon, IPC handler registry, auto-update, deep linking, activity detection, keybind manager
  • Preload bridge (preload.ts): Exposes window.backspace API via contextBridge with full sandbox isolation (contextIsolation: true, nodeIntegration: false, sandbox: true)
  • Renderer: The standard web client, detecting Electron via typeof window.backspace !== 'undefined'

The desktop package compiles to CommonJS (module: "commonjs") targeting ES2022. Electron version: 40+.


Window Management

Creation (main.ts:createWindow())

Default size: 1280 x 800
Minimum size: 940 x 500
Title bar: hiddenInset (macOS), hidden with titleBarOverlay (Windows/Linux)
Title bar overlay: bg #0b0b10, symbol #d8d8de, height 32px
Background color: #313338

State Persistence

Window state is saved to {userData}/window-state.json:

interface WindowState {
  width: number;
  height: number;
  x?: number;
  y?: number;
  isMaximized: boolean;
}
Event Behavior
resize / move Debounced save (300ms) via saveWindowState()
close Immediate save before hide
maximize Saves isMaximized: true; position/size stored from getNormalBounds() (pre-maximize geometry)
restore Validates saved bounds against current displays; strips position if window would be off-screen

Bounds validation (validateWindowBounds): Uses screen.getDisplayMatching() to find the nearest display, then checks if the window rectangle overlaps the display's work area. If not visible, position is stripped and Electron auto-centers.

Close Behavior

Close does not quit the app. The close event is intercepted; the window is hidden instead. The isQuitting flag gates actual destruction. True quit only happens via:

  • Tray menu "Quit"
  • app.quit() (Cmd+Q on macOS)
  • before-quit lifecycle event

URL Loading Priority

  1. BACKSPACE_URL environment variable (managed deployments)
  2. Saved instance URL from {userData}/instance-url.json
  3. No URL: loads resources/instance-picker.html (local HTML file)

Instance URL management functions: loadInstanceUrl(), saveInstanceUrl(), clearInstanceUrl() — all operate on {userData}/instance-url.json.

Focus Tracking

Window focus/blur events send window-focus-changed (boolean) to the renderer via IPC. The web client uses this for notification suppression (no desktop notifications when the window is focused).

setWindowOpenHandler intercepts all window.open() calls. HTTP/HTTPS URLs are opened in the default browser via shell.openExternal(). All popup windows are denied (action: 'deny').

In-Instance /join/ Interception

Click-handlers on https://... URLs use setWindowOpenHandler (packages/desktop/src/main.ts:391). The handler intercepts URLs whose origin matches a connected instance and whose pathname starts with /join/, routing them in-app via the open-internal-route IPC channel instead of shell.openExternal.

State plumbing:

  • Main process maintains knownInstanceOrigins: Set<string> populated via set-connected-origins IPC pushes from the renderer. Synchronously readable from inside setWindowOpenHandler (which must return its result synchronously).
  • Renderer's instanceStore subscribes its own instances selector and forwards the connected-origin list (home + connected remotes) to main on every change, including initial mount.
  • packages/web/src/platform/deepLink.ts (useDeepLinkHandler) subscribes to onOpenInternalRoute and calls navigate(path).

Predicate scope: only intercept URLs whose host matches a currently connected instance. Invites for unknown instances or disconnected federated peers still open externally — that's the entry point for JoinPage's federation-redirect flow and must not be hijacked.

Dev-mode caveat: if a developer runs Electron pointed at the Vite dev server (http://localhost:5173) while the API runs on http://localhost:3005, window.location.origin won't match the API origin and interception will not trigger. Production and standard-Electron-pointed-at-server dev are unaffected.


Instance Picker

When no instance URL is configured, the app loads resources/instance-picker.html — a self-contained HTML page where the user enters their Backspace instance URL. The renderer communicates the chosen URL back via the set-instance-url IPC handler.

After navigation (both to an instance URL and back to the picker), the main process forces Electron to re-evaluate drag regions by momentarily resizing the window (+1px then back).

Non-destructive "Change Instance" navigation

The tray menu, macOS app menu, and recovery surface all include a "Change Instance" option. This navigation is non-destructive: the saved instance URL is preserved when navigating to the picker. The picker's init() function reads the current saved URL via getInstanceUrl() IPC and, if one exists:

  • Pre-fills the URL input with the current value.
  • Shows a Cancel button (hidden by default; only shown when a saved URL exists).
  • Switches the header copy from "Welcome to Backspace / Connect to your instance" to "Switch instance / Connect to a different Backspace instance, or cancel to stay."

Cancel button behavior: Clicking Cancel re-saves the existing URL via setInstanceUrl (idempotent) and navigates back to it. The saved URL is only overwritten when the user explicitly clicks Connect on a different URL. This means the user can always back out of an accidental "Change Instance" click.

Loading state: setLoading(true) — invoked when Connect is clicked — disables both the Connect button and the Cancel button to prevent a race between the setInstanceUrl calls.


Auto-Launch (Start with OS)

Settings stored in {userData}/auto-launch.json:

interface AutoLaunchSettings {
  openAtLogin: boolean;   // default: false; disk cache, NOT source of truth
  startMinimized: boolean; // default: true; disk cache; OS-authoritative on Windows
}

Source of Truth

The OS is the source of truth for openAtLogin on all platforms. Disk is used only to recover values Electron does not expose:

  • Windows: OS-authoritative for openAtLogin (via executableWillLaunchAtLogin, which honours Task Manager's StartupApproved\Run disable). For startMinimized: derived from launchItems[].args when an entry exists; falls back to the disk cache when no entry exists, so the user's preference survives an off/on cycle.
  • macOS: OS-authoritative for openAtLogin. Disk-cached for startMinimized (no introspection available).
  • Linux: OS-authoritative for openAtLogin. Disk-cached for startMinimized (we deliberately do not parse Exec= lines from .desktop files; out-of-band edits are rare and parsing shell-quoted strings is fragile).

Platform-Specific Implementation (applyLoginItemSettings())

Platform Method Key parameters Rationale
macOS app.setLoginItemSettings({ openAtLogin, openAsHidden, args }) openAsHidden: startMinimized; args: ['--hidden'] when startMinimized Both detection paths covered (legacy wasOpenedAsHidden for macOS < 13 and --hidden argv for macOS 13+)
Windows app.setLoginItemSettings({ openAtLogin, enabled, path, args, name }) enabled: openAtLogin; path: process.execPath; args: ['--hidden'] when startMinimized; name: 'Backspace' enabled is required to clear Task Manager's StartupApproved\Run disable when re-enabling. path/args enable correct matching in subsequent getLoginItemSettings.
Linux app.setLoginItemSettings({ openAtLogin, name, path?, args? }) name: 'backspace' (deterministic .desktop filename); path: $APPIMAGE when running as AppImage; args: ['--hidden'] when startMinimized name ensures stable ~/.config/autostart/backspace.desktop path. AppImage path tracks updates.

Startup Re-Apply

The unconditional startup re-apply was removed (it was overwriting user changes made via Task Manager / System Settings). Today the only re-apply happens on Linux/AppImage and only when needed:

if linux AND $APPIMAGE is set:
  read ~/.config/autostart/backspace.desktop → recordedExecPath (null if file missing)
  if saved.openAtLogin AND recordedExecPath != null AND $APPIMAGE != recordedExecPath:
    re-apply to refresh the autostart entry's Exec= path
  (a missing .desktop file is treated as user-disabled — never recreated here)

This keeps AppImage updates working (the AppImage moved to a new path → autostart entry needs the new path) without overriding any user-level OS state on Windows or macOS.

Hidden-Launch Detection

At ready-to-show:

  • process.argv.includes('--hidden') — primary signal on all platforms (we pass args: ['--hidden'] everywhere when startMinimized).
  • app.getLoginItemSettings().wasOpenedAsHidden — macOS-only fallback for the legacy openAsHidden path (macOS < 13).

If either is true, the window is created but not shown (stays in tray).

Pure Helpers

Pure logic lives in packages/desktop/src/autoLaunch.ts with vitest coverage in autoLaunch.test.ts:

  • deriveStartMinimizedFromArgs(args) — used by both IPC handlers on Windows.
  • parseExecPathFromDesktopFile(content) — used by the Linux/AppImage path-refresh.
  • shouldReapplyAppImage(currentAppImagePath, recordedExecPath) — used by the Linux/AppImage path-refresh.

Tray Icon

Icon Loading (loadTrayIcon())

Platform Source Notes
macOS resources/tray-iconTemplate.png (+ @2x) Template image: solid black + alpha at 22×22 / 44×44; OS recolours for light/dark/active
Windows resources/tray-icon.ico Multi-size .ico (16/20/24/32/40/48); Windows + Electron auto-pick best size for current DPI
Linux resources/tray-icon.png Single 22×22 colored PNG (AppIndicator / StatusNotifier convention); no runtime resize
Fallback Programmatic 16×16 BGRA buffer Blurple circle (#5865f2); should not trigger now that templates ship populated

All four assets are produced by scripts/gen-icons.mjs from assets/brand/{mark.svg, mark-mono-dark.svg} — see the icon system spec for the full output matrix and scripts/gen-icons.README.md for regeneration workflow.

Context Menu

Item Action
Show Backspace window.show() + focus()
Hide window.hide()
Change Instance Load picker (non-destructive — saved URL preserved), show + focus
Quit Set isQuitting = true, app.quit()

Tray click toggles window visibility (show/hide).


Deep Linking

Protocol: backspace://

Registered via app.setAsDefaultProtocolClient('backspace') and in electron-builder.yml under protocols.

Platform Handling

Platform Mechanism
macOS app.on('open-url') event
Windows/Linux second-instance event (via single-instance lock); deep link extracted from commandLine args
Cold launch Deep link arg stored in pendingDeepLink, delivered after ready-to-show

Flow

  1. handleDeepLink(url) receives a backspace:// URL
  2. If window exists: sends deep-link IPC to renderer, shows + focuses window
  3. If app not ready: stores in pendingDeepLink for delivery after ready-to-show

Single Instance Lock

app.requestSingleInstanceLock() ensures only one instance runs. Second launch:

  • Deep link arg is forwarded to the existing instance
  • Existing window is restored/shown/focused
  • Second instance quits immediately

Auto-Update

Powered by electron-updater. Loaded via require() (not import) for graceful degradation when not available.

Configuration (initAutoUpdater())

autoDownload: true
autoInstallOnAppQuit: true
Publish: GitHub (TheZwiss/backspace)

Signing status (as of v1.0.0): all builds are unsigned. Consequences:

  • macOS: Squirrel.Mac refuses to apply unsigned updates — auto-update is effectively disabled on macOS until a Developer ID certificate + notarization are added to the CI build. Users update manually from the releases page. First launch requires right-click → Open (Gatekeeper).
  • Windows: NSIS auto-update works unsigned; SmartScreen warns on first install only.
  • Linux: AppImage auto-update works unsigned.

CI publishes via .github/workflows/release.yml (tag v* on the public repo): native runners for mac (arm64+x64), win (x64+arm64), linux (x64, arm64), each job uploading its installers, .blockmaps, and platform latest*.yml manifest to a single draft release. The draft must be published manually — drafts are invisible to electron-updater.

Check Schedule

Trigger Delay
Initial check 10 seconds after app ready
Periodic check Every 4 hours
Manual check check-for-updates IPC from renderer

Event Flow (main -> renderer)

Event IPC Channel Payload Condition
Update found update-available { version } Always
Download complete update-downloaded { version } Always
Error update-error { message, releaseUrl } Only if updateConfirmed is true (download failed after update was confirmed)

Check-phase errors (network, auth, 404) are silently ignored — nothing actionable for the user.

Install

install-update IPC triggers autoUpdater.quitAndInstall().

Recovery Integration

All autoUpdater events update the RecoveryStateStore (drives tray + macOS menu UI dynamically). Existing renderer IPC channels (update-available, update-downloaded, update-error) are preserved unchanged.

On update-downloaded, a native OS notification fires only when mainWindow?.isFocused() is false — symmetric suppression across normal and recovery modes (the in-app banner / Restart button is visible to a focused user; the notification covers minimized/tray/background-desktop cases). Notification click calls autoUpdater.quitAndInstall() directly (force-kill fix path — see Recovery Mode section).

Win32 only: app.setAppUserModelId('com.backspace.desktop') is set early in startup so notifications attribute to "Backspace" instead of "Electron" in Windows Action Center.

extractErrorCode(err) in recovery.ts extracts the code field from electron-updater errors when present (string only); used to populate RecoveryState.lastUpdateError.code.


Recovery Mode

When the renderer fails to load or boot, the main process surfaces a native recovery UI (resources/recovery.html) loaded into the existing main window. Recovery is the escape hatch for failure modes that the in-app ErrorBoundary cannot catch — pre-render module-init throws, JS bundle/Electron incompatibility, network failures, renderer process crashes, and unrecoverable hangs.

Source files:

  • packages/desktop/src/recovery.ts — state store, event handlers, action funnel, menu builders
  • packages/desktop/src/instanceUrl.ts — shared instance-URL helpers (used by main.ts and recovery.ts)
  • packages/desktop/resources/recovery.html — UI page, vanilla HTML/CSS/JS

State Model

RecoveryStateStore (singleton in recovery.ts) owns:

interface RecoveryState {
  mode: 'normal' | 'recovery';
  reason: { code: 'load-failed' | 'render-gone' | 'unresponsive' | 'renderer-stalled'; detail: string } | null;
  updateState: 'idle' | 'checking' | 'downloading' | 'downloaded' | 'error';
  updateVersion: string | null;
  lastUpdateError: { message: string; code: string | null; at: number } | null;
  lastCheckResult: 'up-to-date' | 'failed' | null;  // transient, 5s decay
}

Two flags not in the public state: inRecoveryMode (private to the store, guards loadFile(recovery.html) re-entry / loop prevention) and updateConfirmed (local to main.ts:initAutoUpdater, controls whether updater errors get pushed to the renderer).

Detection Paths

Mechanism Catches Notes
did-fail-load Network/HTTP transport failures (DNS, refused, TLS, transport-level) Filtered by isMainFrame; ignores errorCode === -3 (ERR_ABORTED). 5xx with body does NOT fire — falls through to boot timer.
render-process-gone Renderer process termination Filtered by reason; clean-exit ignored. Triggers on crashed, killed, oom, launch-failed, integrity-failure.
unresponsive Main thread blocked >10s 10s grace period; cancelled by responsive event. Matches Chrome's "page not responding" pattern.
Boot-completion ping JS exceptions during boot, module-init throws, broken preload calls — failures the other events don't catch window.backspace.rendererReady() from web side; main-side timer (20s, packaged builds only, http(s):// URLs only)

The boot ping is not a heartbeat — it is a one-shot per-navigation signal. Web-side call sites:

  • packages/web/src/App.tsxuseEffect with [] deps, fires on first commit (semantic: "renderer survived render," not "data loaded")
  • packages/web/src/main.tsxErrorBoundary.componentDidCatch, fires when in-app error UI mounts (so the boot timer doesn't override the ErrorBoundary fallback 20s later)

Main-side gating uses a per-navigation pingReceivedThisNav flag (reset on did-navigate, set in handleRendererReady). If the ping arrives BEFORE the timer is armed — the typical SPA case, because useEffect runs in a microtask after bundle execution + React render, which is before window.onload that did-finish-load waits on — armBootTimer checks the flag and short-circuits. If the ping arrives AFTER the timer is armed (less-common ordering), it clears the existing timer via clearBootTimer. Either path means a healthy renderer never trips false recovery. The bootArmed flag retains its role: it ensures a clearBootTimer call inside the timeout callback is a no-op if the timer was already disarmed by the ping.

Navigation-aware arming: did-navigate (top-level non-same-document) clears any pending timer, resets pingReceivedThisNav, and queues a fresh arm for did-finish-load. did-navigate-in-page (SPA routing) is ignored, so React Router channel switches don't trip the timer.

Recovery UI

recovery.html is loaded into the existing mainWindow via loadFile(). Mirrors instance-picker.html drag-region pattern (32px titlebar with -webkit-app-region: drag, content container with no-drag).

Page reads initial state via getRecoveryState() IPC and subscribes to recovery-state-changed events for live updates. All button clicks dispatch through a single recovery-action IPC channel with strict allowlist validation in main.

Button Visible Enabled
Reload always always
Restart to Install Update updateState === 'downloaded' always when visible
Check for Updates always not in 'checking' / 'downloading' / 'downloaded'
Change Instance always always
Open Releases Page updateState === 'error' always when visible
Quit Backspace always always

Change Instance from recovery is non-destructive. The saved URL is not cleared when navigating to the picker; see the Instance Picker section above for the full behavior (pre-filled input, Cancel button, header copy update).

Hint text is computed as a function of (reason.code, updateState) — see code in recovery.html. The renderer-stalled text intentionally avoids claiming an update is the cause (slow Pi/cold cache could also trigger it).

lastCheckResult provides transient inline feedback ("You're up to date" / "Update check failed") with 5s auto-decay. Without this, the user has no signal that a Check for Updates click ran when the result is no-update.

Cmd/Ctrl+R is wired as a keyboard shortcut for Reload.

Observability Logging

recovery.ts emits structured console.log lines on entry and exit so smoke-test scripts can grep stderr without UI introspection:

Event Log line
Recovery entered [recovery] entered: <code> — <detail>
Exited via Reload [recovery] exited (reload)
Exited via Change Instance [recovery] exited (change-instance)

The enter log fires after the state update but before the re-entry guard, so repeated entry (reason update with no re-navigation) also logs — useful for diagnostics.

Loop Prevention / Contained Failure

If recovery.html itself fails to load (corrupt resources, packaging bug), did-fail-load re-fires inside the recovery context. The isInRecoveryMode guard prevents infinite reload loops — state.reason updates for display purposes but no second loadFile() is issued. User-visible outcome is a blank window with tray-only escape (Quit). This is contained failure, not graceful failure: a corrupt recovery.html means a corrupt build that requires a fresh install.

Hidden-Launch Override

When the app is launched with --hidden (autostart), enterRecoveryMode() always force-shows + focuses the main window. Without this, a silent boot failure during autostart would never surface to the user.

Force-Kill Fix

The recovery surface's "Restart to Install Update" button — and the native notification's click handler — both call autoUpdater.quitAndInstall() directly. They do not rely on autoInstallOnAppQuit's on-quit hook.

Why this matters: a user who Task-Manager-kills a broken app never triggers the on-quit hook; the downloaded update sits on disk. On next launch, the app loads the same broken old version. With recovery active, the user lands in recovery → clicks Restart → install applies cleanly via the direct call. The on-quit hook bypass is no longer fatal.

Inter-Module Wiring

recovery.ts cannot import from main.ts (would create a cycle). Wiring is contract-based:

Concern Mechanism
Shared instance URL helpers Both files import from instanceUrl.ts
mainWindow reference recovery.ts holds its own ref via setMainWindow(win) setter; main.ts calls on createWindow() and closed
autoUpdater reference setAutoUpdater(au) setter on recovery.ts; main calls inside initAutoUpdater() success path
Quit invariant main.ts exports requestQuit() (sets isQuitting + app.quit()); recovery uses callback registered via setOnQuitRequested(cb)
Tray + macOS menu recovery.ts exports pure buildTrayMenuTemplate / buildAppMenuTemplate; subscriber in main.ts calls them and applies via Menu.buildFromTemplate
Boot-stall callback setOnBootStall(cb) on recovery.ts; main.ts does not need to wire this (recovery.ts wires it to its own enterRecoveryMode at module load)

Manual Smoke Checklist

Executed before each release. See docs/superpowers/specs/2026-05-03-electron-recovery-mode-design.md §9 for the 14-scenario checklist (plus scenario 15 added during implementation).


Notifications

main.ts:showNotification(title, body, onClick?) — uses Electron's Notification API.

  • Checks Notification.isSupported() before showing
  • silent: false (plays system sound)
  • Click handler: defaults to show + focus the main window; an optional onClick parameter overrides this (used by the update-ready notification to call autoUpdater.quitAndInstall() directly)

Badge count: set-badge-count IPC calls app.setBadgeCount() (macOS dock badge, Windows taskbar overlay).

Win32 attribution: app.setAppUserModelId('com.backspace.desktop') set early in startup so notifications attribute to "Backspace" in Windows Action Center.


Application Menu

macOS

Full menu bar with:

  • App menu: About, Change Instance, Hide/Unhide, Quit
  • Edit: Undo, Redo, Cut, Copy, Paste, Select All
  • Window: Minimize, Zoom, Front

Windows/Linux

Hidden menu bar (frameless window), but an Edit menu is still registered so keyboard accelerators (Ctrl+C/V/X/Z/A) work.


Screen Share Integration

The main process intercepts getDisplayMedia() via session.defaultSession.setDisplayMediaRequestHandler().

Flow

  1. Handler invoked by Chromium when renderer calls navigator.mediaDevices.getDisplayMedia()
  2. Main process enumerates sources via desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 }, fetchWindowIcons: true })
  3. Sources serialized (id, name, thumbnail data URL, app icon data URL, isScreen flag) and sent to renderer via screen-share-sources IPC
  4. Renderer shows custom picker UI, user selects a source
  5. Renderer sends screen-share-selected IPC with sourceId (or null to cancel) and shareAudio flag
  6. Main process calls callback({ video: selectedSource, audio: 'loopback' }) (audio only if shareAudio is true)

No sources (0 results) typically means Screen Recording permission not granted on macOS.

For full screen share configuration (resolution, bitrate, codec), see voice.md.


Cache Clearing

On every app launch (app.whenReady), the main process purges stale caches:

await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
await session.defaultSession.clearCache();

This ensures the renderer always loads fresh code after updates.


GTK Version Override

On Linux, Electron 36+ defaults to GTK 4 on GNOME, which crashes if GTK 2/3 libraries are loaded in the same process (common with uiohook-napi). The app forces GTK 3:

app.commandLine.appendSwitch('gtk-version', '3');

IPC Handler Registry

All handlers registered in main.ts:registerIpcHandlers().

Fire-and-Forget (ipcMain.on)

Channel Direction Payload Action
show-notification R->M { title, body } Show native notification
set-badge-count R->M number Set dock/taskbar badge
minimize-window R->M Minimize window
maximize-window R->M Toggle maximize/unmaximize
close-window R->M Close (hides to tray)
install-update R->M autoUpdater.quitAndInstall()
check-for-updates R->M autoUpdater.checkForUpdates()
screen-share-selected R->M sourceId, shareAudio? Safety net (actual handler is ipcMain.once in display media flow)
keybinds-sync R->M KeybindConfig[] keybindManager.updateKeybinds()
set-connected-origins R->M string[] Update knownInstanceOrigins set (used by in-instance /join/ interception)
renderer-ready R->M Boot-completion ping; disarms boot timer
recovery-action R->M RecoveryAction enum Recovery page button dispatcher (allowlist-validated)

Request/Response (ipcMain.handle)

Channel Direction Returns Action
get-instance-url R->M string | null Load saved instance URL
set-instance-url R->M void Save URL, navigate window to it
clear-instance-url R->M void Delete saved URL, load picker
get-app-version R->M string app.getVersion()
get-auto-launch-settings R->M { openAtLogin, startMinimized } Merge OS state with saved prefs
set-auto-launch-settings R->M { openAtLogin, startMinimized } Save + apply to OS
get-current-activity R->M Activity | null Current detected game activity
check-accessibility R->M boolean macOS accessibility permission check
get-recovery-state R->M RecoveryState Recovery page reads initial state on mount

Main -> Renderer Events

Channel Payload Trigger
window-focus-changed boolean Window focus/blur
deep-link string (URL) backspace:// protocol activation
update-available { version } electron-updater
update-downloaded { version } electron-updater
update-error { message, releaseUrl } electron-updater (only after confirmed update)
screen-share-sources ElectronScreenSource[] Display media handler
activity-detected Activity | null Activity detector poll
keybind-action { actionId, pressed } KeybindManager match
accessibility-status { trusted } macOS accessibility check result
keybind-hook-error { message } uIOhook start failure
open-internal-route string (path) In-instance /join/ interception: renderer navigates to path instead of opening externally
recovery-state-changed RecoveryState Store subscriber, mode-gated to mode === 'recovery'

Preload Bridge (window.backspace)

The preload script exposes the window.backspace API via contextBridge.exposeInMainWorld. TypeScript declarations are in packages/web/src/platform/electron.d.ts.

Detection: typeof window.backspace !== 'undefined' (see platform.ts:isElectron()).

API Surface

Method / Property Type Direction Notes
platform NodeJS.Platform read process.platform value
minimize() fire R->M
maximize() fire R->M Toggles maximize
close() fire R->M Hides to tray
showNotification(title, body) fire R->M
setBadgeCount(count) fire R->M
onUpdateAvailable(cb) listen M->R
onUpdateDownloaded(cb) listen M->R
onUpdateError(cb) listen M->R
installUpdate() fire R->M
checkForUpdates() fire R->M
getVersion() invoke R->M Returns Promise<string>
onWindowFocusChange(cb) listen M->R
onDeepLink(cb) listen M->R
onScreenShareSources(cb) listen M->R
selectScreenSource(id, audio?) fire R->M
getInstanceUrl() invoke R->M Returns Promise<string | null>
setInstanceUrl(url) invoke R->M Returns Promise<void>
clearInstanceUrl() invoke R->M Returns Promise<void>
getAutoLaunchSettings() invoke R->M
setAutoLaunchSettings(s) invoke R->M
onActivityDetected(cb) listen M->R Returns cleanup function () => void
getCurrentActivity() invoke R->M Returns Promise<Activity | null>
syncKeybinds(keybinds) fire R->M
onKeybindAction(cb) listen M->R Returns cleanup function
onAccessibilityStatus(cb) listen M->R Returns cleanup function
onKeybindHookError(cb) listen M->R Returns cleanup function
checkAccessibility() invoke R->M Returns Promise<boolean>
setConnectedOrigins(origins) fire R->M Push connected-instance origin list to main (in-instance /join/ interception)
onOpenInternalRoute(cb) listen M->R Returns cleanup function; cb receives a path string to navigate in-app
rendererReady() fire R->M Boot-completion ping; semantic: "renderer survived render"
getRecoveryState() invoke R->M Returns Promise<RecoveryState>
onRecoveryStateChanged(cb) listen M->R Returns cleanup function; recovery.html subscribes
recoveryAction(action) fire R->M Single channel for all recovery button clicks

Direction legend: fire = ipcRenderer.send (no response), invoke = ipcRenderer.invoke (returns Promise), listen = ipcRenderer.on (event subscription).


Activity Detection

Overview

Detects running games/applications by polling the OS process list every 15 seconds and matching process names against a game dictionary.

Game Dictionary

Two formats supported:

// Legacy bare array (version 0)
GameEntry[]

// Versioned object
{ version: number; games: GameEntry[] }
interface GameEntry {
  id: string;        // unique identifier, e.g. "cs2"
  name: string;      // display name, e.g. "Counter-Strike 2"
  processes: string[]; // executable names, e.g. ["cs2.exe", "cs2"]
  type?: string;     // "playing" | "listening" | "watching" | "streaming" (default: "playing")
}

Dictionary Loading Strategy

  1. Startup (startActivityDetection): Load best local source — cache file first ({userData}/games-cache.json), fall back to bundled seed (resources/games.json)
  2. Background sync (syncDictionary): Fire-and-forget async fetch from GitHub after startup

Remote Sync (syncDictionary())

Remote URL: https://raw.githubusercontent.com/TheZwiss/backspace/main/packages/desktop/resources/games.json

Step 1: Determine best local version (cache vs seed, whichever has higher version)
Step 2: Fetch remote with conditional request (ETag)
Step 3: If remote version > local version → atomic write to cache, save ETag, hot-swap

ETag-based conditional fetching:

  • ETag stored at {userData}/games-cache-etag.txt
  • Sent as If-None-Match header; 304 response = no update needed
  • Follows single redirects (301/302, common for GitHub raw)
  • 10-second timeout

Atomic file writes (atomicWrite()): Writes to {path}.tmp then fs.renameSync() into place. Prevents corrupt cache on crash.

Hot-swap (hotSwapDictionary()): Replaces gameEntries and processMap in memory without resetting currentGameId or currentActivity. Active detection state survives dictionary updates.

Process Polling

Interval: 15 seconds (POLL_INTERVAL_MS). First poll runs immediately on start.

Guard: isPolling flag prevents overlapping polls if a previous execFile hasn't returned.

Error handling: First execFile failure logs warning and stops detection entirely (stopActivityDetection()). The hasErrored flag prevents repeated log spam.

Platform Commands

Platform Command Output Format
macOS ps -c -A -o comm One process name per line (header: COMM)
Linux ps -A -o comm One process name per line (header: COMM or COMMAND)
Windows tasklist /fo csv /nh CSV: "ImageName","PID","SessionName","Session#","MemUsage"

Max buffer: 1MB. Process names extracted and lowercased into a Set<string>.

Matching Algorithm (poll())

  1. Parse running processes into a lowercase Set<string>
  2. Iterate gameEntries in dictionary order (first match wins = priority)
  3. For each entry, check if any of its processes (lowercased) are in the running set
  4. Game detected (new or changed): Set currentGameId, build Activity object with timestamps.start = Date.now(), fire onChangeCallback
  5. Same game still running: No-op (no IPC sent)
  6. Game exited (was detected, now gone): Clear currentGameId and currentActivity, fire callback with null
  7. No game (and none before): No-op

Activity Object

interface Activity {
  type: string;       // from GameEntry.type, default "playing"
  name: string;       // from GameEntry.name
  details?: string;   // unused currently
  state?: string;     // unused currently
  timestamps?: { start?: number; end?: number };
}

Lifecycle

  • Start: startActivityDetection(callback) — called from app.whenReady()
  • Stop: stopActivityDetection() — called from before-quit
  • Query: getCurrentActivity() — exposed via get-current-activity IPC handle

Global Keybind Manager

Overview

Captures global keyboard and mouse events via uiohook-napi (OS-level input hook) and matches them against user-configured keybinds. Works even when the Backspace window is not focused.

Native Keycode to DOM Code Mapping

uIOhook reports hardware scan codes. The web UI stores keybinds as djb2 hashes of DOM KeyboardEvent.code strings. The UIOHOOK_TO_DOM_CODE lookup table bridges the two.

Mapped key ranges:

Category Examples
Letters A-Z (keycodes 16-50)
Digits 0-9 (keycodes 2-11)
Function keys F1-F24 (keycodes 59-107)
Modifiers ControlLeft/Right, AltLeft/Right, ShiftLeft/Right, MetaLeft/Right
Special Backspace, Tab, Enter, CapsLock, Escape, Space
Navigation PageUp/Down, Home, End, Arrows, Insert, Delete
Punctuation Semicolon, Equal, Comma, Minus, Period, Slash, Backquote, Brackets, Backslash, Quote
Numpad Numpad0-9, NumpadMultiply/Add/Subtract/Decimal/Divide
Locks NumLock, ScrollLock, PrintScreen

djb2 Hash Function

function djb2(code: string): number {
  let hash = 5381;
  for (let i = 0; i < code.length; i++) {
    hash = ((hash << 5) + hash + code.charCodeAt(i)) | 0;
  }
  return hash >>> 0;
}

This hash is used identically in:

  • keybindManager.ts:djb2() — main process, matching against native events
  • useKeybinds.ts:browserCodeToUiohook() — renderer, web fallback path
  • KeybindsPanel.tsx:codeToNumeric() — renderer, recording keybinds in settings UI

The pre-computed UIOHOOK_TO_HASH map converts uIOhook keycodes directly to djb2 hashes at module load time.

KeybindConfig

interface KeybindConfig {
  actionId: string;      // e.g. "toggleMute", "pushToTalk"
  keys: number[];        // djb2 hashes of DOM code strings
  mouseButton?: number;  // uIOhook mouse button index (3=middle, 4=back, 5=forward)
}

KeybindManager Class

State:

  • keybinds: KeybindConfig[] — current bindings synced from renderer
  • pressedKeys: Set<number> — currently held keys (djb2 hashes)
  • activeActions: Set<string> — actions whose keybind is currently satisfied
  • window: BrowserWindow | null — target for IPC sends
  • started: boolean — whether uIOhook is running

Lifecycle

  1. updateKeybinds(keybinds) — called when renderer syncs new bindings via keybinds-sync IPC

    • Replaces stored keybinds
    • Releases any active actions whose binding was removed
    • Auto-starts uIOhook if keybinds exist and hook not running
    • Auto-stops uIOhook if keybinds list becomes empty
  2. start() — registers uIOhook event listeners and calls uIOhook.start()

    • On macOS: checks systemPreferences.isTrustedAccessibilityClient(true) first (the true parameter triggers the OS permission prompt)
    • If not trusted: sends accessibility-status event to renderer and returns without starting
    • On start failure: sends keybind-hook-error to renderer
  3. stop() — releases all active actions, clears state, removes listeners, calls uIOhook.stop()

    • Called from app.on('before-quit')

Event Processing

Key down (onKeyDown):

  1. Convert uIOhook keycode to djb2 hash via UIOHOOK_TO_HASH
  2. Add hash to pressedKeys
  3. evaluateKeybinds(): for each keybind (no mouseButton, not already active), check if all keys are in pressedKeys → if yes, activate and send pressed: true

Key up (onKeyUp):

  1. Convert keycode to hash, remove from pressedKeys
  2. checkReleases(): for each active action (no mouseButton), check if any required key is no longer pressed → if so, deactivate and send pressed: false

Mouse down (onMouseDown):

  1. Ignore buttons 1 (left) and 2 (right) — only extra buttons (3+) are bindable
  2. evaluateKeybindsWithMouse(button): for keybinds matching this mouseButton that aren't already active, check modifier keys → activate

Mouse up (onMouseUp):

  1. Ignore buttons 1 and 2
  2. checkMouseReleases(button): deactivate actions bound to this mouse button

IPC Output

All matched actions sent to renderer as: keybind-action { actionId: string, pressed: boolean }

This is critical for push-to-talk: the pressed: true unmutes, pressed: false re-mutes. Toggle actions (mute, deafen, camera, etc.) only trigger on pressed: true.

macOS Accessibility Permission

uIOhook requires Accessibility permission on macOS to capture global input events.

Method prompt param Effect
start()isTrustedAccessibilityClient(true) true Checks + shows OS permission dialog if not trusted
checkAccessibility()isTrustedAccessibilityClient(false) false Checks without prompting

On non-macOS platforms, checkAccessibility() always returns true.


Keybind System (Web Side)

Keybind Store (keybindStore.ts)

Persisted via Zustand persist middleware to localStorage key backspace-keybinds (version 1).

interface Keybind {
  actionId: string;
  keys: number[];          // djb2 hashes, sorted ascending
  mouseButton?: number;    // 3=middle, 4=back, 5=forward
  displayLabel: string;    // human-readable, captured at record time
}

Blacklisted mouse buttons: 1 (left), 2 (right) — setKeybind() silently ignores these.

Conflict detection: findConflict(keys, mouseButton?, excludeActionId?) checks for exact key+mouse match against existing bindings.

Bindable Actions

Action ID Label Type
toggleMute Toggle Mute toggle
toggleDeafen Toggle Deafen toggle
pushToTalk Push to Talk hold
toggleCamera Toggle Camera toggle
toggleScreenShare Toggle Screen Share toggle
disconnect Disconnect toggle

useKeybinds Hook

Three parallel systems:

1. PTT Lifecycle — When pushToTalk keybind exists and user is in voice: activates PTT mode (pttActive: true), force-mutes the user. Deactivates when keybind removed or user leaves voice.

2. Electron IPC Bridge — Active when isElectron() and keybinds exist:

  • Syncs keybind config to main process via syncKeybinds()
  • Subscribes to onKeybindAction() for matched events from uIOhook
  • Cleanup on unmount

3. Web Fallback — Always active (both web and Electron):

  • Capture-phase keydown/keyup/mousedown/mouseup listeners on window
  • Converts KeyboardEvent.code to djb2 hash via inline browserCodeToUiohook()
  • Same evaluation logic as KeybindManager (track pressed keys, match keybinds, detect releases)
  • Input suppression: Skips single character keys (no modifiers) when an input/textarea/contentEditable is focused
  • Mouse button mapping: Browser button index -> uIOhook: { 1: 3, 3: 4, 4: 5 } (middle, back, forward)

Deduplication: dispatchKeybindAction() uses a 100ms cooldown per actionId:pressed pair to prevent double-firing when both the native hook and web fallback trigger simultaneously (common when the Electron window is focused).

Action Dispatch (dispatchKeybindAction())

Only dispatches when user is in a voice channel (currentVoiceChannelId exists).

Checks space mute/deafen enforcement state before dispatching mute/deafen actions (see voice.md for voice moderation details).

Action Trigger Behavior
toggleMute pressed: true handleMuteAction() (respects space enforcement)
toggleDeafen pressed: true handleDeafenAction()
toggleCamera pressed: true handleCameraAction()
toggleScreenShare pressed: true handleScreenShareAction()
disconnect pressed: true handleDisconnectAction()
pushToTalk pressed: true/false setMuted(!pressed) + broadcastVoiceStatus()

Toggle actions only fire on pressed: true. Push-to-talk fires on both press (unmute) and release (mute).


Build System

electron-builder Configuration (electron-builder.yml)

appId: com.backspace.desktop
productName: Backspace
artifactName: "${productName}-${version}-${arch}.${ext}"
output: dist-electron

Build Targets

Platform Formats
macOS dmg, zip
Windows nsis (allows custom install dir)
Linux AppImage, deb

Build Commands

Command Description
pnpm build TypeScript compile + electron-builder (current platform)
pnpm build:all Cross-platform: --mac --win --linux --arm64 --x64
pnpm dev Compile TypeScript + launch Electron (with icon setup)

Native Module Handling

Dependency: uiohook-napi (native N-API addon for global input hooks)

Rebuild: electron-rebuild -f -w uiohook-napi runs on postinstall to compile for the build machine's Electron ABI.

ASAR unpacking: All .node files are unpacked from the ASAR archive (asarUnpack: "**/*.node"). Native modules cannot load from inside ASAR.

Build exclusions: Host-compiled artifacts are excluded from the ASAR to prevent them from shadowing platform-correct prebuilts:

- "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**"

npmRebuild: false — electron-builder's built-in rebuild is disabled; the postinstall script handles it.

afterPack Hook (CRITICAL)

File: scripts/afterPack.js

Problem: electron-rebuild (postinstall) compiles uiohook-napi for the BUILD machine (e.g., macOS arm64), placing the binary in build/Release/. The node-gyp-build loader checks build/Release/ BEFORE prebuilds/{platform}/. Without cleanup, cross-platform builds (e.g., building Windows packages on macOS) would ship the macOS binary, causing immediate crashes on the target platform.

Solution (two steps):

  1. Remove host-compiled artifacts: Deletes build/, build.bak/, and bin/ directories from the unpacked uiohook-napi in the output.

  2. Strip foreign prebuilts: Removes prebuilds/{platform}-{arch}/ directories for platforms other than the build target. Saves ~1-2MB per build.

Path resolution: On macOS, resources live inside {productName}.app/Contents/Resources/; on Windows/Linux, under resources/. The hook resolves the correct path via context.electronPlatformName.

WARNING: Removing or disabling this hook will cause Windows and Linux builds to crash on launch. This is documented in project memory as a critical constraint.

Icon Generation

All desktop and web brand assets are generated by scripts/gen-icons.mjs from sources in assets/brand/. Run via pnpm gen-icons after artwork changes; commit the diff. The generator uses sharp (resize / SVG → PNG / squircle composite), png-to-ico (multi-size .ico), and png2icons (.icns). Output is byte-stable for a given lockfile.

Sources:

  • app-icon.svg — flat-vector B-badge. Drives app-icon outputs <128 px (favicons, small .ico reps, small Linux launcher reps) where pixel-grid alignment beats 3D detail.
  • app-icon-x1.png (149) / app-icon-x2.png (294) / app-icon-x3.png (440) / app-icon-1024.png (1024) — 3D-rendered B-badge at four native resolutions. Drives app-icon outputs ≥128 px (PWA, dock, launcher, homescreen, in-app sidebar logo). The generator picks the smallest source whose dimension is ≥ the target output size, so every output is a downscale (no upscale anywhere). After resize, a 22 %-radius rounded-square mask clips the corners to transparent — matches Apple's macOS template ratio and the existing app-icon.svg geometry, so launchers/docks/homescreens that render the icon as-is produce the rounded silhouette they expect.
  • mark.svg — bare gradient B. Drives the PWA maskable inner (60 % scale on #1d1d1b) and the Win/Linux tray icons.
  • mark-mono-dark.svg — solid-black B. Drives the macOS menu-bar template tray icon (alpha + black; OS recolours).

The dev script copies the committed build/icon.icns into Electron's bundled Resources/electron.icns so the macOS dev dock shows the Backspace mark instead of the default Electron logo. This dev-only patch is independent of how .icns is generated.

See scripts/gen-icons.README.md for the regeneration workflow and version-bump caveat (sharp / png-to-ico / png2icons upgrades change encoder output, requiring a follow-up regen+commit).

Auto-Update Publishing

publish:
  - provider: github
    owner: TheZwiss
    repo: backspace

GitHub releases are the update source. The electron-updater library handles checking, downloading, and applying updates.


Persisted Files (userData)

userData Folder Location

The runtime userData folder is named Backspace on every platform:

Platform Path
macOS ~/Library/Application Support/Backspace/
Linux ~/.config/Backspace/
Windows %APPDATA%\Backspace\

Electron's default app.getName() reads package.json's name, which in this monorepo is @backspace/desktop — that would land userData under a nested @backspace/desktop/ folder. To prevent the monorepo's internal package name from leaking into a user-facing filesystem path, main.ts calls app.setName('Backspace') at module load, before any app.getPath('userData') consumer runs. electron-builder's productName: Backspace only renames the bundle metadata (Backspace.app, executable, installer, app menu) — it does not affect runtime userData.

One-Time Migration

Earlier builds wrote to <appData>/@backspace/desktop/. On first launch after the rename, migrateUserData() (in userDataMigration.ts) atomically moves that folder to <appData>/Backspace/ and removes the now-empty @backspace/ parent. The migration is conservative: if the new folder already exists and is non-empty, it skips the move rather than clobbering existing state. Failures are logged, not thrown — a failed migration leaves the user with a fresh-install state, which is degraded but not broken.

Files

File Content Purpose
instance-url.json { url: string } Saved instance URL
window-state.json WindowState Window position, size, maximize state
auto-launch.json AutoLaunchSettings Open at login + start minimized prefs
games-cache.json VersionedDictionary Cached remote game dictionary
games-cache-etag.txt ETag string For conditional HTTP requests

App Lifecycle Summary

Startup Sequence (app.whenReady())

  1. Set application menu (platform-specific)
  2. Clear service worker cache + HTTP cache
  3. Register setDisplayMediaRequestHandler for screen share
  4. Register all IPC handlers
  5. Create main window (with state restoration)
    • After the BrowserWindow is constructed, setMainWindow(mainWindow) and attachRecoveryHandlers(mainWindow) are called. The closed event handler calls setMainWindow(null).
  6. Create tray icon
  7. Initialize auto-updater (10s delayed first check)
  8. Wire recovery store subscriber (rebuilds tray + macOS menu on every state change; pushes recovery-state-changed to renderer when in recovery mode)
  9. setOnQuitRequested(requestQuit) so recovery's Quit button uses the same isQuitting + app.quit() pattern as the tray
  10. Start activity detection (immediate first poll, 15s interval, background remote sync)
  11. Linux/AppImage path-refresh: re-apply autostart entry if $APPIMAGE path changed (conditional; no-op on Windows/macOS)
  12. Check for deep link in launch args

Shutdown Sequence (before-quit)

  1. Set isQuitting = true (allows window close to proceed)
  2. Stop activity detection (clear interval, null callback)
  3. Stop keybind manager (release active actions, stop uIOhook)