Files

1006 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`:
```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 | 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, `.blockmap`s, 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:
```typescript
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.tsx``useEffect` with `[]` deps, fires on first commit (semantic: "renderer survived render," not "data loaded")
- `packages/web/src/main.tsx``ErrorBoundary.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:
```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) |
| `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:
```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<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
```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<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).
```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 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
```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)
### 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)