33 KiB
Desktop & Electron System
Source files:
packages/desktop/src/main.ts— Main process: window management, tray, IPC handlers, auto-update, deep links, app lifecyclepackages/desktop/src/preload.ts— Context bridge: exposeswindow.backspaceAPI to rendererpackages/desktop/src/activityDetector.ts— Process polling, game dictionary loading/sync, activity change detectionpackages/desktop/src/keybindManager.ts— Global keybinds via uIOhook, native keycode mapping, press/release trackingpackages/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 fallbackpackages/web/src/platform/electron.d.ts— TypeScript declarations forwindow.backspacepackages/web/src/platform/platform.ts—isElectron()/isElectronMac()/getElectronAPI()helperspackages/desktop/electron-builder.yml— Build config, protocol registration, afterPack hookpackages/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): Exposeswindow.backspaceAPI viacontextBridgewith full sandbox isolation (contextIsolation: true,nodeIntegration: false,sandbox: true) - Renderer: The standard web client, detecting Electron via
typeof window.backspace !== 'undefined'
The desktop package compiles to CommonJS (module: "commonjs") targeting ES2022. Electron version: 40+.
Window Management
Creation (main.ts:createWindow())
Default size: 1280 x 800
Minimum size: 940 x 500
Title bar: hiddenInset (macOS), hidden with titleBarOverlay (Windows/Linux)
Title bar overlay: bg #0b0b10, symbol #d8d8de, height 32px
Background color: #313338
State Persistence
Window state is saved to {userData}/window-state.json:
interface WindowState {
width: number;
height: number;
x?: number;
y?: number;
isMaximized: boolean;
}
| Event | Behavior |
|---|---|
| resize / move | Debounced save (300ms) via saveWindowState() |
| close | Immediate save before hide |
| maximize | Saves isMaximized: true; position/size stored from getNormalBounds() (pre-maximize geometry) |
| restore | Validates saved bounds against current displays; strips position if window would be off-screen |
Bounds validation (validateWindowBounds): Uses screen.getDisplayMatching() to find the nearest display, then checks if the window rectangle overlaps the display's work area. If not visible, position is stripped and Electron auto-centers.
Close Behavior
Close does not quit the app. The close event is intercepted; the window is hidden instead. The isQuitting flag gates actual destruction. True quit only happens via:
- Tray menu "Quit"
app.quit()(Cmd+Q on macOS)before-quitlifecycle event
URL Loading Priority
BACKSPACE_URLenvironment variable (managed deployments)- Saved instance URL from
{userData}/instance-url.json - 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:
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 both
openAtLogin(viaexecutableWillLaunchAtLogin, which honours Task Manager'sStartupApproved\Rundisable) andstartMinimized(derived fromlaunchItems[].args). - macOS: OS-authoritative for
openAtLogin. Disk-cached forstartMinimized(no introspection available). - Linux: OS-authoritative for
openAtLogin. Disk-cached forstartMinimized(we deliberately do not parseExec=lines from.desktopfiles; 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 passargs: ['--hidden']everywhere whenstartMinimized).app.getLoginItemSettings().wasOpenedAsHidden— macOS-only fallback for the legacyopenAsHiddenpath (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 |
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
handleDeepLink(url)receives abackspace://URL- If window exists: sends
deep-linkIPC to renderer, shows + focuses window - If app not ready: stores in
pendingDeepLinkfor delivery afterready-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
- Handler invoked by Chromium when renderer calls
navigator.mediaDevices.getDisplayMedia() - Main process enumerates sources via
desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 }, fetchWindowIcons: true }) - Sources serialized (id, name, thumbnail data URL, app icon data URL, isScreen flag) and sent to renderer via
screen-share-sourcesIPC - Renderer shows custom picker UI, user selects a source
- Renderer sends
screen-share-selectedIPC withsourceId(ornullto cancel) andshareAudioflag - Main process calls
callback({ video: selectedSource, audio: 'loopback' })(audio only ifshareAudiois true)
No sources (0 results) typically means Screen Recording permission not granted on macOS.
For full screen share configuration (resolution, bitrate, codec), see voice.md.
Cache Clearing
On every app launch (app.whenReady), the main process purges stale caches:
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
await session.defaultSession.clearCache();
This ensures the renderer always loads fresh code after updates.
GTK Version Override
On Linux, Electron 36+ defaults to GTK 4 on GNOME, which crashes if GTK 2/3 libraries are loaded in the same process (common with uiohook-napi). The app forces GTK 3:
app.commandLine.appendSwitch('gtk-version', '3');
IPC Handler Registry
All handlers registered in main.ts:registerIpcHandlers().
Fire-and-Forget (ipcMain.on)
| Channel | Direction | Payload | Action |
|---|---|---|---|
show-notification |
R->M | { title, body } |
Show native notification |
set-badge-count |
R->M | number |
Set dock/taskbar badge |
minimize-window |
R->M | — | Minimize window |
maximize-window |
R->M | — | Toggle maximize/unmaximize |
close-window |
R->M | — | Close (hides to tray) |
install-update |
R->M | — | autoUpdater.quitAndInstall() |
check-for-updates |
R->M | — | autoUpdater.checkForUpdates() |
screen-share-selected |
R->M | sourceId, shareAudio? |
Safety net (actual handler is ipcMain.once in display media flow) |
keybinds-sync |
R->M | KeybindConfig[] |
keybindManager.updateKeybinds() |
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:
// Legacy bare array (version 0)
GameEntry[]
// Versioned object
{ version: number; games: GameEntry[] }
interface GameEntry {
id: string; // unique identifier, e.g. "cs2"
name: string; // display name, e.g. "Counter-Strike 2"
processes: string[]; // executable names, e.g. ["cs2.exe", "cs2"]
type?: string; // "playing" | "listening" | "watching" | "streaming" (default: "playing")
}
Dictionary Loading Strategy
- Startup (
startActivityDetection): Load best local source — cache file first ({userData}/games-cache.json), fall back to bundled seed (resources/games.json) - 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-Matchheader; 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())
- Parse running processes into a lowercase
Set<string> - Iterate
gameEntriesin dictionary order (first match wins = priority) - For each entry, check if any of its
processes(lowercased) are in the running set - Game detected (new or changed): Set
currentGameId, buildActivityobject withtimestamps.start = Date.now(), fireonChangeCallback - Same game still running: No-op (no IPC sent)
- Game exited (was detected, now gone): Clear
currentGameIdandcurrentActivity, fire callback withnull - No game (and none before): No-op
Activity Object
interface Activity {
type: string; // from GameEntry.type, default "playing"
name: string; // from GameEntry.name
details?: string; // unused currently
state?: string; // unused currently
timestamps?: { start?: number; end?: number };
}
Lifecycle
- Start:
startActivityDetection(callback)— called fromapp.whenReady() - Stop:
stopActivityDetection()— called frombefore-quit - Query:
getCurrentActivity()— exposed viaget-current-activityIPC handle
Global Keybind Manager
Overview
Captures global keyboard and mouse events via uiohook-napi (OS-level input hook) and matches them against user-configured keybinds. Works even when the Backspace window is not focused.
Native Keycode to DOM Code Mapping
uIOhook reports hardware scan codes. The web UI stores keybinds as djb2 hashes of DOM KeyboardEvent.code strings. The UIOHOOK_TO_DOM_CODE lookup table bridges the two.
Mapped key ranges:
| Category | Examples |
|---|---|
| Letters | A-Z (keycodes 16-50) |
| Digits | 0-9 (keycodes 2-11) |
| Function keys | F1-F24 (keycodes 59-107) |
| Modifiers | ControlLeft/Right, AltLeft/Right, ShiftLeft/Right, MetaLeft/Right |
| Special | Backspace, Tab, Enter, CapsLock, Escape, Space |
| Navigation | PageUp/Down, Home, End, Arrows, Insert, Delete |
| Punctuation | Semicolon, Equal, Comma, Minus, Period, Slash, Backquote, Brackets, Backslash, Quote |
| Numpad | Numpad0-9, NumpadMultiply/Add/Subtract/Decimal/Divide |
| Locks | NumLock, ScrollLock, PrintScreen |
djb2 Hash Function
function djb2(code: string): number {
let hash = 5381;
for (let i = 0; i < code.length; i++) {
hash = ((hash << 5) + hash + code.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
This hash is used identically in:
keybindManager.ts:djb2()— main process, matching against native eventsuseKeybinds.ts:browserCodeToUiohook()— renderer, web fallback pathKeybindsPanel.tsx:codeToNumeric()— renderer, recording keybinds in settings UI
The pre-computed UIOHOOK_TO_HASH map converts uIOhook keycodes directly to djb2 hashes at module load time.
KeybindConfig
interface KeybindConfig {
actionId: string; // e.g. "toggleMute", "pushToTalk"
keys: number[]; // djb2 hashes of DOM code strings
mouseButton?: number; // uIOhook mouse button index (3=middle, 4=back, 5=forward)
}
KeybindManager Class
State:
keybinds: KeybindConfig[]— current bindings synced from rendererpressedKeys: Set<number>— currently held keys (djb2 hashes)activeActions: Set<string>— actions whose keybind is currently satisfiedwindow: BrowserWindow | null— target for IPC sendsstarted: boolean— whether uIOhook is running
Lifecycle
-
updateKeybinds(keybinds)— called when renderer syncs new bindings viakeybinds-syncIPC- 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
-
start()— registers uIOhook event listeners and callsuIOhook.start()- On macOS: checks
systemPreferences.isTrustedAccessibilityClient(true)first (thetrueparameter triggers the OS permission prompt) - If not trusted: sends
accessibility-statusevent to renderer and returns without starting - On start failure: sends
keybind-hook-errorto renderer
- On macOS: checks
-
stop()— releases all active actions, clears state, removes listeners, callsuIOhook.stop()- Called from
app.on('before-quit')
- Called from
Event Processing
Key down (onKeyDown):
- Convert uIOhook keycode to djb2 hash via
UIOHOOK_TO_HASH - Add hash to
pressedKeys evaluateKeybinds(): for each keybind (no mouseButton, not already active), check if allkeysare inpressedKeys→ if yes, activate and sendpressed: true
Key up (onKeyUp):
- Convert keycode to hash, remove from
pressedKeys checkReleases(): for each active action (no mouseButton), check if any required key is no longer pressed → if so, deactivate and sendpressed: false
Mouse down (onMouseDown):
- Ignore buttons 1 (left) and 2 (right) — only extra buttons (3+) are bindable
evaluateKeybindsWithMouse(button): for keybinds matching this mouseButton that aren't already active, check modifier keys → activate
Mouse up (onMouseUp):
- Ignore buttons 1 and 2
checkMouseReleases(button): deactivate actions bound to this mouse button
IPC Output
All matched actions sent to renderer as: keybind-action { actionId: string, pressed: boolean }
This is critical for push-to-talk: the pressed: true unmutes, pressed: false re-mutes. Toggle actions (mute, deafen, camera, etc.) only trigger on pressed: true.
macOS Accessibility Permission
uIOhook requires Accessibility permission on macOS to capture global input events.
| Method | prompt param |
Effect |
|---|---|---|
start() → isTrustedAccessibilityClient(true) |
true | Checks + shows OS permission dialog if not trusted |
checkAccessibility() → isTrustedAccessibilityClient(false) |
false | Checks without prompting |
On non-macOS platforms, checkAccessibility() always returns true.
Keybind System (Web Side)
Keybind Store (keybindStore.ts)
Persisted via Zustand persist middleware to localStorage key backspace-keybinds (version 1).
interface Keybind {
actionId: string;
keys: number[]; // djb2 hashes, sorted ascending
mouseButton?: number; // 3=middle, 4=back, 5=forward
displayLabel: string; // human-readable, captured at record time
}
Blacklisted mouse buttons: 1 (left), 2 (right) — setKeybind() silently ignores these.
Conflict detection: findConflict(keys, mouseButton?, excludeActionId?) checks for exact key+mouse match against existing bindings.
Bindable Actions
| Action ID | Label | Type |
|---|---|---|
toggleMute |
Toggle Mute | toggle |
toggleDeafen |
Toggle Deafen | toggle |
pushToTalk |
Push to Talk | hold |
toggleCamera |
Toggle Camera | toggle |
toggleScreenShare |
Toggle Screen Share | toggle |
disconnect |
Disconnect | toggle |
useKeybinds Hook
Three parallel systems:
1. PTT Lifecycle — When pushToTalk keybind exists and user is in voice: activates PTT mode (pttActive: true), force-mutes the user. Deactivates when keybind removed or user leaves voice.
2. Electron IPC Bridge — Active when isElectron() and keybinds exist:
- Syncs keybind config to main process via
syncKeybinds() - Subscribes to
onKeybindAction()for matched events from uIOhook - Cleanup on unmount
3. Web Fallback — Always active (both web and Electron):
- Capture-phase
keydown/keyup/mousedown/mouseuplisteners onwindow - Converts
KeyboardEvent.codeto djb2 hash via inlinebrowserCodeToUiohook() - Same evaluation logic as KeybindManager (track pressed keys, match keybinds, detect releases)
- Input suppression: Skips single character keys (no modifiers) when an input/textarea/contentEditable is focused
- Mouse button mapping: Browser button index -> uIOhook:
{ 1: 3, 3: 4, 4: 5 }(middle, back, forward)
Deduplication: dispatchKeybindAction() uses a 100ms cooldown per actionId:pressed pair to prevent double-firing when both the native hook and web fallback trigger simultaneously (common when the Electron window is focused).
Action Dispatch (dispatchKeybindAction())
Only dispatches when user is in a voice channel (currentVoiceChannelId exists).
Checks space mute/deafen enforcement state before dispatching mute/deafen actions (see voice.md for voice moderation details).
| Action | Trigger | Behavior |
|---|---|---|
toggleMute |
pressed: true |
handleMuteAction() (respects space enforcement) |
toggleDeafen |
pressed: true |
handleDeafenAction() |
toggleCamera |
pressed: true |
handleCameraAction() |
toggleScreenShare |
pressed: true |
handleScreenShareAction() |
disconnect |
pressed: true |
handleDisconnectAction() |
pushToTalk |
pressed: true/false |
setMuted(!pressed) + broadcastVoiceStatus() |
Toggle actions only fire on pressed: true. Push-to-talk fires on both press (unmute) and release (mute).
Build System
electron-builder Configuration (electron-builder.yml)
appId: com.backspace.desktop
productName: Backspace
artifactName: "${productName}-${version}-${arch}.${ext}"
output: dist-electron
Build Targets
| Platform | Formats |
|---|---|
| macOS | dmg, zip |
| Windows | nsis (allows custom install dir) |
| Linux | AppImage, deb |
Build Commands
| Command | Description |
|---|---|
pnpm build |
TypeScript compile + electron-builder (current platform) |
pnpm build:all |
Cross-platform: --mac --win --linux --arm64 --x64 |
pnpm dev |
Compile TypeScript + launch Electron (with icon setup) |
Native Module Handling
Dependency: uiohook-napi (native N-API addon for global input hooks)
Rebuild: electron-rebuild -f -w uiohook-napi runs on postinstall to compile for the build machine's Electron ABI.
ASAR unpacking: All .node files are unpacked from the ASAR archive (asarUnpack: "**/*.node"). Native modules cannot load from inside ASAR.
Build exclusions: Host-compiled artifacts are excluded from the ASAR to prevent them from shadowing platform-correct prebuilts:
- "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**"
npmRebuild: false — electron-builder's built-in rebuild is disabled; the postinstall script handles it.
afterPack Hook (CRITICAL)
File: scripts/afterPack.js
Problem: electron-rebuild (postinstall) compiles uiohook-napi for the BUILD machine (e.g., macOS arm64), placing the binary in build/Release/. The node-gyp-build loader checks build/Release/ BEFORE prebuilds/{platform}/. Without cleanup, cross-platform builds (e.g., building Windows packages on macOS) would ship the macOS binary, causing immediate crashes on the target platform.
Solution (two steps):
-
Remove host-compiled artifacts: Deletes
build/,build.bak/, andbin/directories from the unpackeduiohook-napiin the output. -
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:
sipsto resize into all required icon sizes (16-1024px, including @2x variants)iconutil -c icnsto pack the iconset
Used in pnpm dev to set the dev Electron icon.
Auto-Update Publishing
publish:
- provider: github
owner: TheZwiss
repo: backspace
GitHub releases are the update source. The electron-updater library handles checking, downloading, and applying updates.
Persisted Files (userData)
| 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())
- Set application menu (platform-specific)
- Clear service worker cache + HTTP cache
- Register
setDisplayMediaRequestHandlerfor screen share - Register all IPC handlers
- Create main window (with state restoration)
- Create tray icon
- Initialize auto-updater (10s delayed first check)
- Start activity detection (immediate first poll, 15s interval, background remote sync)
- Linux/AppImage path-refresh: re-apply autostart entry if
$APPIMAGEpath changed (conditional; no-op on Windows/macOS) - Check for deep link in launch args
Shutdown Sequence (before-quit)
- Set
isQuitting = true(allows window close to proceed) - Stop activity detection (clear interval, null callback)
- Stop keybind manager (release active actions, stop uIOhook)