chore: add system docs, specs, and misc updates from other sessions

- Add complete docs/systems/ reference (18 system docs)
- Add federation relay status doc and prior spec/plan docs
- Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md)
- CLAUDE.md updates
- Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
This commit is contained in:
Jannis Braun
2026-03-31 03:40:34 +02:00
parent 70678ff198
commit a3a7527c9e
25 changed files with 8414 additions and 1243 deletions
+775
View File
@@ -0,0 +1,775 @@
# 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'`).
---
## 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
startMinimized: boolean; // default: true
}
```
### Platform-Specific Implementation (`applyLoginItemSettings()`)
| Platform | Method | Minimized Launch |
|----------|--------|-----------------|
| macOS | `app.setLoginItemSettings({ openAtLogin, openAsHidden })` | `openAsHidden` flag |
| Windows | `app.setLoginItemSettings({ openAtLogin, args, name })` | `--hidden` CLI arg |
| Linux | `app.setLoginItemSettings({ openAtLogin, path, args })` | `--hidden` CLI arg; `path` set to `$APPIMAGE` for AppImage portability |
On startup, settings are re-applied to the OS (`applyLoginItemSettings`) to refresh the login item path (important for AppImage updates where the path changes).
### Launch Detection
Hidden launch is detected at `ready-to-show` via:
- `process.argv.includes('--hidden')` (Windows/Linux)
- `app.getLoginItemSettings().wasOpenedAsHidden` (macOS)
If launched hidden, the window is created but never shown (stays in tray).
---
## Tray Icon
### Icon Loading (`loadTrayIcon()`)
| Platform | Source | Notes |
|----------|--------|-------|
| macOS | `resources/tray-iconTemplate.png` | Template image (auto light/dark); Electron resolves `@2x` variant |
| Windows/Linux | `resources/tray-icon.png` | Colored icon, resized to 16x16 |
| Fallback | Programmatic 16x16 BGRA buffer | Blurple circle (#5865f2) |
### 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()` |
### 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 |
---
## 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>` |
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
**`scripts/gen-icns.sh`** — macOS-only, generates `.icns` from `icon.png` via:
1. `sips` to resize into all required icon sizes (16-1024px, including @2x variants)
2. `iconutil -c icns` to pack the iconset
Used in `pnpm dev` to set the dev Electron icon.
### 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. Sync auto-launch settings with OS
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)