Files
fusion/packages/desktop
gsxdsm c15c78feeb feat: migrate storage from SQLite to PostgreSQL (#1793)
# Migrate storage from SQLite to PostgreSQL — full dashboard cutover

Migrates Fusion's storage layer to the embedded PostgreSQL
`AsyncDataLayer` (the default backend) and **completes the
satellite-store + feature cutover** so every dashboard and Command
Center surface works in PG mode.

## Status — every surface works in embedded-PG mode

Verified live against a running embedded-Postgres dashboard (all
**200**, zero 5xx) and gate-tested (**23 files / 99 tests** on embedded
PG, plus engine-core 294 and ci-shape 63 in the blocking merge gate;
core/engine/cli/dashboard typecheck clean).

| Area | Surfaces | State |
|---|---|---|
| Satellite stores | workflows, todos, insights, research, missions,
goals, mailbox | ✅ |
| Views | artifacts, documents, evals | ✅ |
| Command Center | activity, productivity, team, tokens, tools,
**workflows**, **github**, **signals**, **plugin-activations**, **live**
(all 10) | ✅ |
| Run execution | insight generation, research run execution | ✅
(store-path; AI step needs a provider) |
| Live updates | SSE push for mission/research/insight events | ✅ |
| Workflow editing | create / update / delete / select (+ id counter) |
✅ |
| Engine | mission autopilot, incident-signal ingestion, regression
storm-guard, agent wake-on-message | ✅ |
| Core | tasks, agents, secrets, automations, memory, chat, usage, PRs,
git | ✅ |

## Approach

Each satellite store gets an `Async<Store>` wrapper exposing the sync
store's method names over the existing `async-*-store.ts` helpers;
`get<Store>Store()` returns a `Sync | Async` union; consumers `await`
(harmless on sync), and engine/CLI paths that can't convert use
`instanceof Sync` graceful fallback. Analytics aggregators branch on
`"ping" in dbOrLayer` to run schema-qualified raw SQL over `project.*`
(snake_case) in PG. Executors/orchestrators/autopilot are
await-converted to drive the union store; the async store wrappers
extend `EventEmitter` so SSE live-push fires in both backends.

Not-yet-ported capabilities degrade gracefully (never 500) and are
individually called out in commits.

## Sync with main

The branch is kept continuously merged with `main` (currently through
FN-7845, 2026-07-12); the earlier "final rebase deferred" note no longer
applies. Use **Create a merge commit** (or squash) to land it — GitHub's
rebase-merge cannot replay a merge-maintained branch.

## Residual Review Findings

Multi-agent code review of the PostgreSQL satellite-store ports (U1–U5)
applied 3 safe fixes (see `fix(review): apply autofix feedback`). The
following are **real but gated** — recorded here as follow-up work
rather than auto-applied. All are SQLite→PostgreSQL
**concurrency/atomicity regressions**: the sync stores were immune only
by SQLite's single-writer, single-threaded-handler execution; the async
ports open multi-await read-modify-write windows. **Reachability is low
today** because the execution engines that generate concurrent same-run
mutations (insight run executor, research orchestrator/dispatcher) are
`instanceof`-gated to sync mode in PG. No process-crash class survived
(all engine fallbacks correctly guard the sync store).

- **[P1] Research `appendResearchEvent` dual-write is non-atomic**
(`packages/core/src/async-research-store.ts`, corroborated: adversarial
+ reliability). The `research_run_events` insert (own transaction) and
the `run.events` jsonb update are separate writes — a crash between
them, or two concurrent appends, splits the table count from the jsonb
array. **Fix:** perform the seq-insert and the jsonb update in one
`layer.transactionImmediate`.
- **[P1] Research run terminal-reversion via stale full-row persist**
(`async-research-store.ts` `persistResearchRun`/`updateResearchStatus`).
Concurrent `PATCH /runs/:id/status` + `POST /runs/:id/events` can revert
a terminal run to `running` by overwriting the whole row, bypassing the
transition guard. **Fix:** scoped column `UPDATE`s with a `WHERE status
…` guard, or optimistic version column.
- **[P2] `updateResearchRun`/`updateInsightRun` read-then-write TOCTOU**
— concurrent PATCHes last-writer-wins on the lifecycle merge. **Fix:**
`SELECT … FOR UPDATE` / enclosing transaction.
- **[P2] `upsertRun`/`createRunOrThrowConflict` check-then-create race**
(`async-insight-store.ts`) — two callers can each create an "active"
run. **Fix:** partial unique index on `(projectId, trigger) WHERE status
IN ('pending','running')`.
- **[P3] `createResearchRetryRun` return-value divergence** — sync
returns the pre-update `queued` snapshot; async returns the reloaded
`retry_waiting` run (persisted state is identical). Pick one side for
cross-backend parity.
- **[P2/perf] Mission `getMissionWithHierarchy`/`getMissionHealth` N+1
fan-out** — O(milestones×slices) sequential round-trips hold one pool
slot per request; can starve the pool for large hierarchies. **Fix:**
batched/joined reads.
- **Testing gaps:** no PG-mode concurrency tests (interleaved
status/event mutations), no sync↔async parity assertion for the
lifecycle-error codes, and no mission status/health rollup parity test
vs the sync `MissionStore`.

~~Out of scope (deferred): AI run *execution* (insight/research) +
mission autopilot + live SSE mission events remain sync-gated/degraded
in PG mode.~~ **Since ported** — insight/research run execution, mission
autopilot, and SSE live push all run on the async layer now, which also
makes the concurrency findings above genuinely reachable; they remain
open follow-ups.







---

## Update — 2026-07-12: production-readiness hardening & live acceptance

Everything below landed on this branch since the description above was
written:

**Production blockers from review — fixed**
- `recoverStaleTransitionPending` ported to the async layer (backend
moves write + clear the crash-safe marker; startup/maintenance sweeps no
longer throw).
- Lost-update class fixed: `atomicWriteTaskJson`/`WithAudit` write
changed columns only (full-row upserts silently resurrected stale fields
across concurrent store instances — the "task stuck unplanned forever"
bug).
- First-boot **auto-migration**: booting the PG backend over a project
with a legacy `fusion.db` migrates it automatically (loud failure,
SQLite kept as backup), and the dashboard shows a one-time **"your data
was migrated" banner** with the backup paths and a Need-help Discord
link.
- `pg_dump`/`pg_restore` discovered from common install locations for
embedded-mode backups.
- The PG suite is part of the blocking merge gate (`test:pg-gate`).

**Multi-project isolation (PR #2007, merged into this branch)**
- `project_id` partition key on tasks / archived tasks / config,
`taskProjectScope` threaded through every scan/claim/count, per-project
config rows, layer bound to the project at startup.
- Review P1 follow-up: the shared cold-storage `archive.archived_tasks`
table is also partitioned and all archived-board reads/counts/searches
are scoped.
- Schema drift self-heal generalized to schema-qualified columns so
existing databases upgrade in place.

**Other changes**
- Node settings sync **removed** in PG mode (409
`settings-sync-disabled-postgres`) — nodes share state by connecting to
the same database; auth sync kept (per-machine file).
- Perf (review findings): `listTasks` pushes column filter + ORDER BY +
LIMIT/OFFSET into SQL; `getConversation` capped to the most recent 200
messages.
- Fixed a false "operator action required" pause-abort log fired on
every successfully auto-merged task.

**Live acceptance — PASSED (2026-07-12)**
A sandboxed instance (isolated HOME, embedded PG, real Opus executor)
ran a task through the complete cycle: create → triage (AI spec) →
execute → in-review → AI squash-merge landed on the project's `main` →
done. A write+read sweep of every data surface (settings, comments,
documents, attachments + artifact bridge + artifact edit, chat with real
generation, goals, missions, agent mail, secrets, workflows, memory, CC
analytics) was green on embedded PG.

**Known remaining work**
- The per-project `config` PK re-key has no upgrade path for
pre-isolation embedded-PG databases (needs a real `DROP
CONSTRAINT`/re-key migration; fresh databases are fine).
- `pg_dump`/`pg_restore` binaries are not yet bundled in release
artifacts (PATH/common-location discovery only).
- The satellite-store concurrency findings listed above.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Phil Larson <hello@phillarson.xyz>
Co-authored-by: fusion-merge <fusion-merge@local>
2026-07-13 19:07:58 -07:00
..
2026-07-13 10:32:12 -07:00
2026-07-13 10:32:12 -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 plus the local AI engine 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 the AI engine paused (automation disabled)

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

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 platform close 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)
    • on Windows, close proceeds normally so window-all-closed can quit the Electron app and before-quit can stop the embedded local Fusion runtime
    • on macOS and other non-Windows desktop platforms, if app is not quitting, close is prevented and the window hides to tray/dock for later restore
    • if app is quitting, close proceeds normally on every platform
  • Renderer window:close uses mainWindow.close(), so the custom titlebar close button inherits the same platform policy as the native window close button.
  • Tray Show/Hide Window remains the explicit way to hide or restore the window on all platforms.

Quit cleanup

  • before-quit sets app.isQuitting = true
  • Periodic updater interval is disposed
  • Tray instance is destroyed (tray.destroy())
  • Embedded local runtime cleanup runs via localRuntimeManager.stopLocal(); external CLI runtime mode remains a no-op through the runtime manager
  • 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

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 NSIS installer artifacts: Fusion-<version>-win-x64.exe and Fusion-<version>-win-arm64.exe in packages/desktop/dist-electron/
  • Windows portable artifacts: Fusion-<version>-win-x64-portable.exe and Fusion-<version>-win-arm64-portable.exe in packages/desktop/dist-electron/
  • Silent NSIS installs support a custom destination with /S /D=<absolute path>; keep /D=... as the final installer argument (for example, Fusion-<version>-win-x64.exe /S /D=C:\\Users\\me\\Tools\\fusion).
  • The Windows packaging workflow verifies win*-unpacked contains Electron root runtime resources (chrome_100_percent.pak, chrome_200_percent.pak, and resources.pak) before uploading artifacts.
  • 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.
    • Android: when Android signing secrets are configured, fusion-android-release.apk, fusion-android-release.apk.sha256, fusion-android-release.aab, and fusion-android-release.aab.sha256; otherwise the secret-free fallback publishes fusion-android.apk and fusion-android.apk.sha256 from the Capacitor/Gradle debug APK build.
  • 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:

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 the embedded API server and local AI engine on an ephemeral port
  3. If --paused is set, keep the AI engine in an automation-paused state during startup
  4. 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.