Files
fusion/packages/desktop/README.md
gsxdsm 7d2cf330db feat(FN-5609): add macOS desktop signing configuration and entitlements
Adds macOS code signing and notarization infrastructure (FN-5609), introducing hardened runtime entitlements, electron-builder config for signed builds, and signing-enabled release workflows, with tests validating the configuration and docs covering the signing flow.

Fusion-Task-Id: FN-5609

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5609
2026-05-27 21:31:12 -07:00

458 lines
26 KiB
Markdown
Raw 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.
# @fusion/desktop
Electron desktop shell for Fusion.
This package provides a native Electron wrapper around the existing Fusion dashboard web UI. The desktop shell presents native desktop affordances including a system tray and application menu, with an embedded renderer for production deployments.
## Running the Desktop Shell
### Hot-reload development workflow
Run a single command from the workspace root:
```bash
pnpm --filter @fusion/desktop dev
```
This command now orchestrates the full desktop dev loop:
1. Bundles Electron `main.ts` and `preload.ts` to `packages/desktop/dist`
2. Starts the dashboard Vite renderer dev server (`@fusion/dashboard dev:serve`)
3. Waits for renderer readiness
4. Launches Electron with `--dev` and live renderer reload
By default it uses `http://localhost:5173`. Override with `FUSION_DASHBOARD_URL`.
### Production-style desktop launch (from CLI)
```bash
fn desktop
```
`fn desktop` builds desktop artifacts, starts an embedded dashboard server on an ephemeral port, and launches Electron with embedded renderer assets.
Useful flags:
- `fn desktop --dev` — use dev renderer URL (`FUSION_DASHBOARD_URL` or `http://localhost:5173`)
- `fn desktop --paused` — start with engine paused
## Renderer Architecture
The desktop uses a dual-mode renderer strategy:
### Production Mode (default)
- Loads embedded dashboard assets from `dist/client/` (bundled at build time)
- Uses `window.loadFile()` to load `dist/client/index.html`
- Renderer connects to the embedded API server via IPC (`getServerPort()`)
### Development Mode (`--dev` or `NODE_ENV=development`)
- Loads renderer from `FUSION_DASHBOARD_URL` (defaults to `http://localhost:5173`)
- Uses `window.loadURL()` for live reload support
- Renderer connects to the dev API server
### Renderer Resolution (`src/renderer.ts`)
```typescript
isDevelopmentMode() // Checks NODE_ENV or --dev flag
isUrlRenderer() // true in dev mode, false in production
getRendererUrl() // Returns URL or file:// path
getRendererFilePath() // Returns absolute file path for loadFile()
```
## First-run Shell Onboarding (Desktop)
Desktop boots through a shell-owned mode chooser before mounting the dashboard app when the user has not completed mode selection yet.
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**.
- **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`.
- **Desktop mode restore:** launch mode is stored in `app.getPath("userData")/desktop-launch-mode.json` as `{ "mode": "choose" | "local" | "remote" }` and reused on relaunch.
- **Restore rules:** `choose` keeps chooser-first startup behavior, `local` attempts to start the embedded local runtime on launch, and `remote` skips embedded runtime startup.
- **Failure fallback:** if remembered `local` restore fails, the shell stops partial runtime state, falls back to `choose`, and persists that fallback to avoid broken relaunch loops.
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be created/edited/switched/deleted from the dashboard connection manager.
- **Delete fallback:** if the active profile is deleted, desktop shell settings automatically select the first remaining profile; deleting the final profile leaves a valid empty payload (`activeProfileId: null`, `profiles: []`).
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
### Production vs dev bootstrap behavior
- **Production (`fn desktop`)**: renderer mounts `DesktopShellBootstrap`, which resolves shell mode via preload/IPC and either renders the chooser or mounts the dashboard shell. In remote mode, the dashboard shell opens the native connection onboarding/manager flow instead of the local runtime path.
- **Dev (`pnpm --filter @fusion/desktop dev`)**: same mode bootstrap flow runs; only the renderer source (Vite URL vs bundled file) changes.
## IPC Channel Reference
`src/ipc.ts` registers renderer ↔ main process bridges used by `window.electronAPI` (desktop renderer transport/window controls) and `window.fusionShell` (shared shell connection contract for dashboard code).
### Renderer → Main (`ipcRenderer.invoke`)
| Channel | Direction | Parameters | Returns |
|---|---|---|---|
| `window:minimize` | renderer → main | none | `Promise<void>` |
| `window:maximize` | renderer → main | none | `Promise<boolean>` (new maximized state) |
| `window:close` | renderer → main | none | `Promise<void>` |
| `window:isMaximized` | renderer → main | none | `Promise<boolean>` |
| `app:getSystemInfo` | renderer → main | none | `Promise<{ platform; arch; electronVersion; nodeVersion; appVersion; }>` |
| `app:checkForUpdates` | renderer → main | none | `Promise<{ status: "checking" } \| { status: "unavailable"; reason: string } \| { status: "error"; error: string }>` |
| `app:getServerPort` | renderer → main | none | `Promise<number \| undefined>` (external CLI port when present; otherwise embedded local runtime port when running) |
| `desktopRuntime:getStatus` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
| `desktopRuntime:startLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
| `desktopRuntime:stopLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
| `desktopLaunchMode:getMode` | renderer → main | none | `Promise<"choose" \| "local" \| "remote">` |
| `desktopLaunchMode:setMode` | renderer → main | `mode: "choose" \| "local" \| "remote"` | `Promise<"choose" \| "local" \| "remote">` |
| `tray:updateStatus` | renderer → main | `status: "running" \| "paused" \| "stopped"` | `Promise<void>` |
| `native:showExportDialog` | renderer → main | none | `Promise<string \| null>` |
| `native:showImportDialog` | renderer → main | none | `Promise<string \| null>` |
### Main → Renderer Events (`ipcRenderer.on`)
| Channel | Direction | Payload |
|---|---|---|
| `deep-link` | main → renderer | `DeepLinkResult` (`{ type, id, raw }`) |
| `update-available` | main → renderer | update info object (includes `version`) |
| `update-downloaded` | main → renderer | no payload is currently forwarded by preload |
| `update-not-available` | main → renderer | update info object (typically includes current `version`) |
| `update-error` | main → renderer | `{ message: string }` |
## Local Bundled Runtime Lifecycle
Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) that mirrors the CLI desktop server pattern:
- creates `TaskStore`, calls `init()` and `watch()`
- creates the dashboard server with `createServer(store)`
- listens on an ephemeral port (`0`, never `4040`)
- reports runtime status as:
- `source`: `"embedded-local" | "external-cli" | "none"`
- `state`: `"stopped" | "starting" | "running" | "error"`
- optional `port`, `baseUrl`, and `error`
- keeps shutdown idempotent and exact-once for embedded server close and store close
### Runtime source rules
- **external-cli**: when `FUSION_SERVER_PORT` is provided (for example by `fn desktop`), Electron treats the server as CLI-owned and does **not** start an embedded server. `desktopRuntime:stopLocal` is a no-op in this state and never kills the CLI-owned server.
- **embedded-local**: when started inside Electron via runtime IPC or startup env activation.
- **none**: no active runtime.
### Activation rules
- Desktop does **not** auto-start embedded local runtime by default.
- Embedded local runtime starts at launch only when `FUSION_DESKTOP_MODE=local` is set.
- Future onboarding/connection flows can start/stop embedded local runtime explicitly over IPC.
## Main Process Lifecycle
`src/main.ts` orchestrates module startup in this order:
1. `loadWindowState()`
2. `loadDesktopLaunchMode()`
3. Restore launch mode behavior (`local` attempts embedded runtime start; `remote`/`choose` skip)
4. `createMainWindow(state)`
5. `buildAppMenu({ mainWindow, appName: "Fusion" })`
6. `setupTray(mainWindow, tray)`
7. `registerIpcHandlers(mainWindow, tray)`
8. `registerDeepLinkProtocol()`
9. `setupDeepLinkHandler(mainWindow)`
10. `setupAutoUpdater(mainWindow)`
11. `startUpdateCheckInterval(mainWindow)` (4-hour periodic background checks)
12. `mainWindow.maximize()` when restored state was maximized
### Window state and close-to-tray behavior
- Startup restores width/height from persisted state (fallback: `DEFAULT_WINDOW_STATE`).
- Restored position (`x`, `y`) is validated against `screen.getAllDisplays()` work areas. If the restored window rectangle has less than `64px × 64px` overlap with every display, `x`/`y` are dropped and the OS picks a visible default location while preserving width/height.
- After `loadURL`/`loadFile`, the window is explicitly `show()` + `focus()` on `ready-to-show`, with a 2-second fallback timer that also `show()`/`focus()`es if `ready-to-show` never fires.
- On window close:
- state is saved via `saveWindowState(mainWindow)`
- if app is **not quitting**, close is prevented and the window hides to tray
- if app **is quitting**, close proceeds normally
### Quit cleanup
- `before-quit` sets `app.isQuitting = true`
- Periodic updater interval is disposed
- Tray instance is destroyed (`tray.destroy()`)
- `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate`
## Preload APIs (`window.electronAPI` and `window.fusionShell`)
`src/preload.ts` exposes safe, context-isolated bridges:
- `window.electronAPI`
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
- Desktop runtime: `getDesktopRuntimeStatus()`, `startDesktopLocalRuntime()`, `stopDesktopLocalRuntime()`
- Desktop launch mode: `getDesktopLaunchMode()`, `setDesktopLaunchMode(mode)`
- Native shell management: `openConnectionManager()` (invokes `shell:openConnectionManager`)
- Tray: `updateTrayStatus(status)`
- Native dialogs: `showExportDialog()`, `showImportDialog()`
- Event subscriptions (return unsubscribe functions):
- `onDeepLink(callback)`
- `onUpdateAvailable(callback)`
- `onUpdateDownloaded(callback)`
- `onUpdateNotAvailable(callback)`
- `onUpdateError(callback)`
- `window.fusionShell`
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
- `setActiveProfile()`, `setDesktopMode()`
- `startQrScan()`, `openConnectionManager()`, `subscribe(listener)`
- Together these cover create/delete/switch operations for shell-owned remote profiles without writing to project/global Fusion settings
- `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`.
All preload typings are declared in `src/types.d.ts`.
### Regression coverage locked by tests
Desktop tests under `src/__tests__/` now explicitly lock:
- first-run mode projection and last-used mode restore (`choose`/`local`/`remote`)
- local runtime startup only when local mode is active (and no unexpected startup in remote mode)
- remote mode handoff persistence across relaunch behavior
- preload `fusionShell` bridge channel wiring (`shell:getState`, profile CRUD/switching, mode state, QR, and connection-manager open)
## Module Integration Overview
```text
renderer (window.fusionAPI)
preload.ts (contextBridge)
ipc.ts handlers ───────────► native.ts (dialogs, updater, window state)
├────────────────────────► tray.ts (status + tray menu wiring)
└────────────────────────► main.ts lifecycle orchestration
├─ menu.ts (application menu)
└─ deep-link.ts (fusion:// protocol + routing)
```
## System Tray
- Left-clicking the tray icon toggles the main window visibility.
- Right-click context menu includes:
- **Show/Hide Window** (contextual based on visibility)
- **Pause/Resume Engine** (status toggle placeholder; IPC wiring lands in FN-1076)
- **Quit Fusion**
- Tray tooltip reflects engine status:
- `Fusion — Running`
- `Fusion — Paused`
- `Fusion — Stopped`
- Tray icon is generated from the Fusion four-dot logo.
## Application Menu
The desktop shell installs a native menu with standard shortcuts.
- **macOS:** App, Edit, View, Window, and Help menus (App menu includes **Check for Updates…**).
- **Windows/Linux:** Edit, View, Window, and Help (Help includes **Check for Updates…**).
- Keyboard shortcuts use Electron `CmdOrCtrl` accelerators for cross-platform behavior.
- View menu includes reload, force reload, dev tools toggle, and zoom controls.
## Native Integrations
`src/native.ts` provides desktop-native utilities used by the Electron main process:
- **Settings file dialogs**
- `showExportSettingsDialog(parentWindow?)` opens a save dialog for JSON exports using a default filename like `fusion-settings-YYYY-MM-DD-HHmmss.json`.
- `showImportSettingsDialog(parentWindow?)` opens a single-file JSON picker.
- **Desktop notifications**
- `showDesktopNotification(title, body, options?)` wraps Electron `Notification` with support checks and optional click callback wiring.
- **Auto-updater integration**
- `setupAutoUpdater(mainWindow?)` is idempotent, binds updater listeners once, and runs the initial check only once.
- `triggerUpdateCheck(mainWindow?)` performs on-demand checks (manual menu/IPC trigger) and returns `checking`/`unavailable`/`error` status.
- `startUpdateCheckInterval(mainWindow, intervalMs?)` schedules periodic background checks (default every 4 hours) and returns a disposer for quit cleanup.
- Events forwarded to renderer include `update-available`, `update-downloaded`, `update-not-available`, and `update-error`.
- Auto-update feed metadata is published as GitHub Release assets by `.github/workflows/release.yml` (`latest.yml`, `latest-mac.yml`, `latest-linux.yml`) and is what updater checks resolve against.
- Failures are logged and treated as non-fatal (important for unsigned/local dev builds, which typically surface `update-not-available` or the guarded error path).
- **Window state persistence**
- `loadWindowState()` reads `window-state.json` from `app.getPath("userData")`.
- `saveWindowState(mainWindow)` writes bounds/maximized state atomically (`.tmp` + rename).
- `DEFAULT_WINDOW_STATE` is the fallback (`1280x900`, not maximized).
- **Desktop launch-mode persistence**
- `loadDesktopLaunchMode()` reads `desktop-launch-mode.json` and returns `"choose" | "local" | "remote"` (invalid/missing files fall back to `"choose"`).
- `saveDesktopLaunchMode(mode)` writes the mode atomically (`.tmp` + rename).
## Deep Linking
`src/deep-link.ts` implements `fusion://` protocol support.
### Supported URL patterns
- `fusion://task/FN-123` → task deep link
- `fusion://project/my-app` → project deep link
- `fusion://task/FN-123/extra` → extra segments are ignored
- `fusion://project/my%20app` → ID is URL-decoded
Invalid or unsupported URLs (wrong scheme, missing host, unknown host) are ignored.
### Single-instance behavior and platform differences
- `setupDeepLinkHandler(mainWindow)` owns `app.requestSingleInstanceLock()`.
- If no lock is granted, the app quits to avoid duplicate instances.
- **macOS:** listens to `open-url` events.
- **Windows/Linux:** listens to `second-instance` args and extracts `fusion://` URLs.
- Valid parsed deep links are forwarded to the renderer as `mainWindow.webContents.send("deep-link", result)`.
## Cross-Task API Contract (FN-1075 → FN-1076)
FN-1076 depends on these exact exports and names.
### `src/native.ts`
| Export | Type |
|---|---|
| `showExportSettingsDialog` | `(parentWindow?) => Promise<string \| null>` |
| `showImportSettingsDialog` | `(parentWindow?) => Promise<string \| null>` |
| `showDesktopNotification` | `(title, body, options?) => void` |
| `setupAutoUpdater` | `(mainWindow?) => void` |
| `triggerUpdateCheck` | `(mainWindow?) => Promise<{ status: "checking" } \| { status: "unavailable"; reason: string } \| { status: "error"; error: string }>` |
| `startUpdateCheckInterval` | `(mainWindow, intervalMs?) => () => void` |
| `loadWindowState` | `() => Promise<WindowState \| null>` |
| `saveWindowState` | `(mainWindow) => void` |
| `loadDesktopLaunchMode` | `() => Promise<"choose" \| "local" \| "remote">` |
| `saveDesktopLaunchMode` | `(mode) => Promise<void>` |
| `DEFAULT_WINDOW_STATE` | `WindowState` |
| `WindowState` | `interface` |
### `src/deep-link.ts`
| Export | Type |
|---|---|
| `registerDeepLinkProtocol` | `() => void` |
| `parseDeepLink` | `(url: string) => DeepLinkResult \| null` |
| `handleDeepLink` | `(mainWindow, url: string) => void` |
| `setupDeepLinkHandler` | `(mainWindow) => void` |
| `DeepLinkResult` | `interface` |
## Tray Icons
Tray icons are generated from `packages/dashboard/app/public/logo.svg`.
- Script: `pnpm --filter @fusion/desktop generate:icons`
- Package-local equivalent (from `packages/desktop`): `pnpm generate:icons`
- Generated outputs are committed under `src/icons/`:
- `tray-16.png`
- `tray-32.png`
- `tray-48.png`
## Scripts
- `pnpm --filter @fusion/desktop dev` — hot-reload workflow (main/preload bundle + dashboard Vite dev server + Electron)
- `pnpm --filter @fusion/desktop build` — production desktop build (dashboard client build + main/preload bundle + asset copy)
- `pnpm --filter @fusion/desktop test` — run Vitest suite
- `pnpm --filter @fusion/desktop typecheck` — run TypeScript checks without emitting files
- `pnpm --filter @fusion/desktop generate:icons` — regenerate tray icon PNG assets from the dashboard logo SVG
- `pnpm --filter @fusion/desktop pack` — generate unpacked artifacts via electron-builder (`--dir`)
- `pnpm --filter @fusion/desktop dist` — generate installable desktop artifacts via electron-builder
- `pnpm --filter @fusion/desktop dist:win` — generate Windows installable artifacts (`--win`)
- `pnpm --filter @fusion/desktop dist:mac` — generate macOS installable artifacts (`--mac`)
- `pnpm --filter @fusion/desktop dist:linux` — generate Linux installable artifacts (`--linux`)
- `pnpm dist:desktop:win` — workspace alias to build desktop assets then run Windows packaging
## Packaging
Desktop packaging is configured in `electron-builder.yml`.
- Output directory: `packages/desktop/dist-electron`
- Targets: macOS (`dmg`, `zip`), Windows (`nsis`, `portable`), Linux (`AppImage`, `deb`, `tar.gz`)
- Windows artifacts: `Fusion-<version>-win-x64.exe` and `Fusion-<version>-win-arm64.exe` (both NSIS + portable variants) in `packages/desktop/dist-electron/`
- Binary GitHub Release workflow (`.github/workflows/release.yml`) now attaches desktop artifacts for all supported platforms:
- Electron-updater feed files are also published per platform: `latest.yml` (Windows), `latest-mac.yml` (macOS), and `latest-linux.yml` (Linux). `setupAutoUpdater` / `triggerUpdateCheck` resolve these feeds from the GitHub Release channel.
- Windows: x64 + arm64 outputs (NSIS + portable), matching `.exe.sha256` sidecars, and `.blockmap` files.
- macOS: `Fusion-<version>-mac-arm64.dmg`, `Fusion-<version>-mac-x64.dmg`, matching `.zip` variants, `.sha256` sidecars, and `.blockmap` files.
- Linux: `Fusion-<version>-linux-x64.AppImage` and `Fusion-<version>-linux-arm64.AppImage` with matching `.sha256` sidecars, plus best-effort `.deb` and `.tar.gz` outputs per arch (`Fusion-<version>-linux-x64.{deb,tar.gz}` / `Fusion-<version>-linux-arm64.{deb,tar.gz}`) and sidecars when available on the runner image.
- Tag-less release rehearsal workflow (`.github/workflows/test-release.yml`) mirrors that artifact collection path without publishing a real GitHub Release.
- Linux ARM64 artifacts are cross-built from the `ubuntu-latest` x64 runner by passing `electron-builder --linux --x64 --arm64`; running/validating arm64 installers still requires an arm64 Linux device or emulator.
- Linux desktop artifacts can include detached GPG signature sidecars (`*.AppImage.asc`, `*.deb.asc`, `*.tar.gz.asc`) when Linux signing secrets are configured in CI; full Linux desktop code-signing rollout remains tracked in FN-5605.
- Linux `.deb` and `.tar.gz` outputs are best-effort and may be absent on some runner images without failing the release.
### macOS code-signing and notarization
The macOS desktop release path signs and notarizes desktop bundles through electron-builder in CI.
- Required CI secrets:
- `APPLE_CERTIFICATE_BASE64` (base64-encoded `.p12` Developer ID Application certificate)
- `APPLE_CERTIFICATE_PASSWORD`
- `APPLE_ID`
- `APPLE_TEAM_ID`
- `APPLE_APP_PASSWORD` (mapped to electron-builder env var `APPLE_APP_SPECIFIC_PASSWORD`)
- Signing uses electron-builder `CSC_LINK` / `CSC_KEY_PASSWORD`.
- Notarization uses electron-builder + `xcrun notarytool` with `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID`.
- With `mac.notarize: true`, CI verifies stapled notarization for `.dmg` and `.app` outputs.
- If `APPLE_CERTIFICATE_BASE64` is empty (for example forked PR contexts), the workflow still publishes unsigned `.dmg` / `.zip` via the unsigned step and passes `-c.mac.notarize=false`; signed verification is skipped in that path.
- Hardened runtime entitlements are pinned in `packages/desktop/build/entitlements.mac.plist`. Any entitlement changes require a follow-up task.
- Local signing/notarization is opt-in; developers can set the same env vars locally to mirror CI behavior.
### Linux signing
Every signed Linux desktop release includes `*.asc` detached signature sidecars alongside the binary artifacts.
Verification example:
```bash
gpg --import KEYS && gpg --verify Fusion-<version>-linux-x64.AppImage.asc Fusion-<version>-linux-x64.AppImage
```
The public key (`KEYS`) must be distributed out-of-band. See `docs/CODE_SIGNING.md` for canonical setup, key publication, and troubleshooting guidance.
- Isolated manual Windows build path: `.github/workflows/desktop-windows.yml` (`workflow_dispatch` on `windows-latest`) runs `electron-builder --win --x64 --arm64 --publish never`.
- ARM64 artifacts are cross-built on the `windows-latest` x64 runner; execution/validation still requires a Windows ARM64 device or emulator.
- Deep link protocol: `fusion://`
- Publish provider: GitHub (`gsxdsm/fusion`)
Run `pnpm --filter @fusion/desktop build` before `pack`/`dist` to ensure `dist/` assets are up to date.
### Windows code-signing
The Windows desktop workflow (`.github/workflows/desktop-windows.yml`) supports conditional Authenticode signing for NSIS + portable EXE outputs.
- Required CI secrets:
- `WINDOWS_CERTIFICATE_BASE64` (base64-encoded `.pfx`)
- `WINDOWS_CERTIFICATE_PASSWORD`
- Signing is handled directly by electron-builder using `CSC_LINK` and `CSC_KEY_PASSWORD`; no separate `signtool` wrapper script is invoked in this workflow.
- If signing secrets are not available (for example, forked PR contexts), the workflow still succeeds and uploads unsigned artifacts; the `Verify signed artifacts` step is skipped.
- Release-attached Windows `.exe` artifacts are currently unsigned in the binary publishing pipeline; code-signing automation for release workflows is tracked by FN-5592.
- Signing policy is pinned in `electron-builder.yml` (`sha256` digest + `http://timestamp.digicert.com`) to match `scripts/sign-windows.ps1` used by CLI binaries.
- Local signing is opt-in: developers who need signed local Windows builds must set `CSC_LINK`/`CSC_KEY_PASSWORD` in their own environment before invoking electron-builder.
## Environment
- `FUSION_DASHBOARD_URL` — override the default dashboard URL in development mode (`http://localhost:5173`)
- `FUSION_SERVER_PORT` — internal: port for embedded API server (set by CLI)
- `FUSION_ELECTRON_BINARY` — path to Electron binary (for testing)
## Build Pipeline
### Development Build (`pnpm --filter @fusion/desktop dev`)
1. Bundle `main.ts` and `preload.ts` with esbuild
2. Start dashboard Vite dev server
3. Launch Electron with `--dev` flag
### Production Build (`pnpm --filter @fusion/desktop build`)
1. Build dashboard client to `packages/dashboard/dist/client/`
2. Bundle `main.ts` and `preload.ts` with esbuild
3. Copy dashboard client to `packages/desktop/dist/client/`
### CLI Launch (`fn desktop`)
1. Build desktop artifacts (unless `--dev`)
2. Start embedded API server on ephemeral port
3. Launch Electron:
- **Production:** Uses embedded renderer assets, `getServerPort()` for API connection
- **Development (`--dev`):** Uses `FUSION_DASHBOARD_URL` for live reload
## Desktop Shell UI Components
- `src/renderer/components/DesktopWrapper.tsx` wraps the dashboard app for Electron-only chrome.
- `src/renderer/components/TitleBar.tsx` implements a custom frameless title bar with Fusion branding, drag region behavior, and window controls (minimize/maximize/close).
- The title bar styling lives in `src/renderer/components/TitleBar.css` and uses dashboard theme tokens (`--surface`, `--border`, `--text`, etc.).
## Desktop Hooks
Reusable renderer hooks in `src/renderer/hooks/` expose Electron runtime capabilities:
- `useElectron()` — runtime detection + typed `electronAPI` access
- `useAutoUpdate()` — update-available subscription + install trigger
- `useDeepLink()` — deep-link subscription and `fusion://task/...` / `fusion://project/...` parsing
## Renderer Entrypoint
- `src/renderer/index.html` mirrors dashboard theme initialization logic with Electron-safe defaults.
- `src/renderer/index.tsx` mounts the dashboard app in `StrictMode` and wraps it in `DesktopWrapper`.
- Unlike the web dashboard entry (`packages/dashboard/app/main.tsx`), this renderer entry does not register service workers and is intended for desktop-only bootstrapping.