# 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.ts` — `isElectron()` / `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`: ```typescript 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). ### External Links `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` 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). The tray menu and macOS app menu both include a "Change Instance" option that clears the saved URL and reloads the picker. --- ## Auto-Launch (Start with OS) Settings stored in `{userData}/auto-launch.json`: ```typescript 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](../superpowers/specs/2026-04-27-icon-system-design.md) 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 | Clear saved URL, load picker, 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) ``` ### 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()`. --- ## Notifications `main.ts:showNotification()` — uses Electron's `Notification` API. - Checks `Notification.isSupported()` before showing - `silent: false` (plays system sound) - Click handler: shows + focuses the main window Badge count: `set-badge-count` IPC calls `app.setBadgeCount()` (macOS dock badge, Windows taskbar overlay). --- ## 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: ```typescript 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: ```typescript 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) | ### 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 | ### 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 | --- ## 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` | | `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` | | `setInstanceUrl(url)` | invoke | R->M | Returns `Promise` | | `clearInstanceUrl()` | invoke | R->M | Returns `Promise` | | `getAutoLaunchSettings()` | invoke | R->M | | | `setAutoLaunchSettings(s)` | invoke | R->M | | | `onActivityDetected(cb)` | listen | M->R | Returns cleanup function `() => void` | | `getCurrentActivity()` | invoke | R->M | Returns `Promise` | | `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` | | `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 | 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: ```typescript // Legacy bare array (version 0) GameEntry[] // Versioned object { version: number; games: GameEntry[] } ``` ```typescript 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`. ### Matching Algorithm (`poll()`) 1. Parse running processes into a lowercase `Set` 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 ```typescript 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 ```typescript 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 ```typescript 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` — currently held keys (djb2 hashes) - `activeActions: Set` — 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). ```typescript 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`) ```yaml 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: ```yaml - "!**/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 three SVG sources in `assets/brand/` (`app-icon.svg`, `mark.svg`, `mark-mono-dark.svg`). Run via `pnpm gen-icons` after artwork changes; commit the diff. The generator uses `sharp` (SVG → PNG), `png-to-ico` (multi-size `.ico`), and `png2icons` (`.icns`). Output is byte-stable for a given lockfile. 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 [the icon system spec](../superpowers/specs/2026-04-27-icon-system-design.md) for the full output matrix and `scripts/gen-icons.README.md` for the regeneration workflow and version-bump caveat. ### Auto-Update Publishing ```yaml 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) | 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) 6. Create tray icon 7. Initialize auto-updater (10s delayed first check) 8. Start activity detection (immediate first poll, 15s interval, background remote sync) 9. Linux/AppImage path-refresh: re-apply autostart entry if `$APPIMAGE` path changed (conditional; no-op on Windows/macOS) 10. 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)