@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:
- Bundles Electron
main.tsandpreload.tstopackages/desktop/dist - Starts the dashboard Vite renderer dev server (
@fusion/dashboard dev:serve) - Waits for renderer readiness
- Launches Electron with
--devand 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_URLorhttp://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 loaddist/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 tohttp://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:
loadWindowState()createMainWindow(state)buildAppMenu({ mainWindow, appName: "Fusion" })setupTray(mainWindow, tray)registerIpcHandlers(mainWindow, tray)registerDeepLinkProtocol()setupDeepLinkHandler(mainWindow)setupAutoUpdater(mainWindow)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
- state is saved via
Quit cleanup
before-quitsetsapp.isQuitting = true- Tray instance is destroyed (
tray.destroy()) mainWindowis nulled onclosedfor clean re-creation on macOSactivate
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 — RunningFusion — PausedFusion — 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
CmdOrCtrlaccelerators 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 likefusion-settings-YYYY-MM-DD-HHmmss.json.showImportSettingsDialog(parentWindow?)opens a single-file JSON picker.
- Desktop notifications
showDesktopNotification(title, body, options?)wraps ElectronNotificationwith support checks and optional click callback wiring.
- Auto-updater integration
setupAutoUpdater(mainWindow?)configureselectron-updater, checks for updates, and relaysupdate-available/update-downloadedevents to the renderer via IPC.- Failures are logged and treated as non-fatal (important for unsigned/local dev builds).
- Window state persistence
loadWindowState()readswindow-state.jsonfromapp.getPath("userData").saveWindowState(mainWindow)writes bounds/maximized state atomically (.tmp+ rename).DEFAULT_WINDOW_STATEis the fallback (1280x900, not maximized).
Deep Linking
src/deep-link.ts implements fusion:// protocol support.
Supported URL patterns
fusion://task/FN-123→ task deep linkfusion://project/my-app→ project deep linkfusion://task/FN-123/extra→ extra segments are ignoredfusion://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)ownsapp.requestSingleInstanceLock().- If no lock is granted, the app quits to avoid duplicate instances.
- macOS: listens to
open-urlevents. - Windows/Linux: listens to
second-instanceargs and extractsfusion://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.pngtray-32.pngtray-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 suitepnpm --filter @fusion/desktop typecheck— run TypeScript checks without emitting filespnpm --filter @fusion/desktop generate:icons— regenerate tray icon PNG assets from the dashboard logo SVGpnpm --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)
- Bundle
main.tsandpreload.tswith esbuild - Start dashboard Vite dev server
- Launch Electron with
--devflag
Production Build (pnpm --filter @fusion/desktop build)
- Build dashboard client to
packages/dashboard/dist/client/ - Bundle
main.tsandpreload.tswith esbuild - Copy dashboard client to
packages/desktop/dist/client/
CLI Launch (fn desktop)
- Build desktop artifacts (unless
--dev) - Start embedded API server on ephemeral port
- Launch Electron:
- Production: Uses embedded renderer assets,
getServerPort()for API connection - Development (
--dev): UsesFUSION_DASHBOARD_URLfor live reload
- Production: Uses embedded renderer assets,
Desktop Shell UI Components
src/renderer/components/DesktopWrapper.tsxwraps the dashboard app for Electron-only chrome.src/renderer/components/TitleBar.tsximplements 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.cssand uses dashboard theme tokens (--surface,--border,--text, etc.).
Desktop Hooks
Reusable renderer hooks in src/renderer/hooks/ expose Electron runtime capabilities:
useElectron()— runtime detection + typedelectronAPIaccessuseAutoUpdate()— update-available subscription + install triggeruseDeepLink()— deep-link subscription andfusion://task/.../fusion://project/...parsing
Renderer Entrypoint
src/renderer/index.htmlmirrors dashboard theme initialization logic with Electron-safe defaults.src/renderer/index.tsxmounts the dashboard app inStrictModeand wraps it inDesktopWrapper.- 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.