Files
fusion/packages/desktop
Fusion de5dd6f7db feat(FN-2370): merge fusion/fn-2370 (auto-resolved)
- test(FN-2370): complete Step 3 — align qa-check template expectation
- test(FN-2370): complete Step 2 — add regression coverage for addComment diagnostics
- test(FN-2370): complete Step 2 — cover addComment warning regressions
- feat(FN-2370): complete Step 1 — log addComment best-effort failures
- feat(FN-2369): merge fusion/fn-2369
- feat(prompts): require lint alongside tests and typecheck in agent instructions
- perf(test): parallelize harder — unlock worker count, split build-output, bump workspace concurrency
- fix(core): recognize legacy kb-* backups and canonicalize .kb/backups settings
- refactor: eliminate remaining 15 any warnings and ratchet rule to error
- refactor: eliminate ~400 no-explicit-any warnings across the workspace
- feat(core): add getErrorMessage helper for narrowing unknown errors
- refactor: fix and tighten mechanical lint rules
- chore(eslint): fix pre-existing errors surfaced by wider .cjs match
- chore(eslint): promote @typescript-eslint/no-unused-vars from warn to error
- refactor(dashboard,desktop,engine): remove unused imports, props, and locals
- refactor(core): remove unused imports, helpers, and dead migration constant
- refactor(cli): remove unused imports and variables
- refactor: adapt resource loader and tool wiring to pi-coding-agent 0.70
- fix: adapt to AgentState.error → errorMessage rename
- refactor: migrate @sinclair/typebox imports to typebox 1.x
- refactor: migrate to ModelRegistry.create factory
- chore: bump pi-coding-agent + pi-ai to 0.70.0
- refactor: remove legacy kb compatibility
- feat: add "Anthropic — via Claude CLI" as a first-class provider
- test(FN-2358): harden clean-worktree CI verification tests
- fix(FN-2352): add structured terminal websocket diagnostics
- fix: use live merge-base for task diff scope
- feat: backfill Claude skills when useClaudeCli toggle flips on
- fix: prevent nested .fusion/.fusion dir from PluginStore path bug
2026-04-24 08:54:25 -07:00
..

@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:

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)

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)

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()

IPC Channel Reference

src/ipc.ts registers the renderer ↔ main process bridge used by window.fusionAPI.

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: "error"; error: string }>
app:getServerPort renderer → main none Promise<number | undefined>
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

Main Process Lifecycle

src/main.ts orchestrates module startup in this order:

  1. loadWindowState()
  2. createMainWindow(state)
  3. buildAppMenu({ mainWindow, appName: "Fusion" })
  4. setupTray(mainWindow, tray)
  5. registerIpcHandlers(mainWindow, tray)
  6. registerDeepLinkProtocol()
  7. setupDeepLinkHandler(mainWindow)
  8. setupAutoUpdater(mainWindow)
  9. 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).
  • Position (x, y) is restored only when both values are present.
  • 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
  • Tray instance is destroyed (tray.destroy())
  • mainWindow is nulled on closed for clean re-creation on macOS activate

Preload API (window.fusionAPI)

src/preload.ts exposes a safe, context-isolated bridge:

  • Window control: minimize(), maximize(), close(), isMaximized()
  • App/system: getSystemInfo(), checkForUpdates(), getServerPort()
  • Tray: updateTrayStatus(status)
  • Native dialogs: showExportDialog(), showImportDialog()
  • Event subscriptions (return unsubscribe functions):
    • onDeepLink(callback)
    • onUpdateAvailable(callback)
    • onUpdateDownloaded(callback)

All preload typings are declared in src/types.d.ts (FusionAPI, SystemInfo, UpdateCheckResult, DeepLinkResult).

Module Integration Overview

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.
  • Windows/Linux: Edit, View, Window, and Help (no App menu).
  • 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?) configures electron-updater, checks for updates, and relays update-available / update-downloaded events to the renderer via IPC.
    • Failures are logged and treated as non-fatal (important for unsigned/local dev builds).
  • 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).

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
loadWindowState () => Promise<WindowState | null>
saveWindowState (mainWindow) => 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

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)
  • 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.

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.