diff --git a/.changeset/fix-desktop-electron-dep.md b/.changeset/fix-desktop-electron-dep.md index d6b3ccfe23..2274662bd1 100644 --- a/.changeset/fix-desktop-electron-dep.md +++ b/.changeset/fix-desktop-electron-dep.md @@ -4,4 +4,4 @@ summary: Fix `fusion desktop` on Windows and published npm installs (Electron dependency, GPU/sandbox flags, dashboard reuse). category: fix -dev: `packages/cli/package.json` now depends on `electron` at runtime; previously the desktop launcher called `require("electron")`, which is only available inside the source checkout (via `pnpm-workspace.yaml` `onlyBuiltDependencies`) and is missing for npm consumers, causing `fusion desktop` to hang or fail silently. The launcher now applies GPU/sandbox-disabling Electron flags only on Windows (`os.platform() === "win32"`), keeps hardware acceleration and the Chromium sandbox on macOS/Linux, exports `FUSION_SERVER_PORT` so the desktop reuses the CLI-started dashboard instead of double-binding ports, and isolates desktop user-data under `~/.fusion/desktop-user-data`. +dev: `packages/cli/package.json` now depends on `electron` at runtime; previously the desktop launcher called `require("electron")`, which is only available inside the source checkout (via `pnpm-workspace.yaml` `onlyBuiltDependencies`) and is missing for npm consumers, causing `fusion desktop` to hang or fail silently. The launcher now applies GPU/sandbox-disabling Electron flags only on Windows (`os.platform() === "win32"`), keeps hardware acceleration and the Chromium sandbox on macOS/Linux, exports `FUSION_SERVER_PORT` so the desktop reuses the CLI-started dashboard instead of double-binding ports, and isolates desktop user-data under `~/.fusion/desktop-user-data`. Relocating the profile performs a one-time copy of the previous default Electron profile (`user-data-migration.ts`) so upgrading operators keep window geometry/session. `packages/desktop/scripts/build.ts` now fails the build if `main.js`/`preload.js`/`client/index.html` are missing from `dist/` or the staged `deploy/dist/`, preventing shipping an incomplete `app.asar`. diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml index 251dd6f9d7..bb4a44b644 100644 --- a/.github/workflows/desktop-windows.yml +++ b/.github/workflows/desktop-windows.yml @@ -89,6 +89,34 @@ jobs: if ($nsis.Count -eq 0) { Write-Error "No NSIS installer artifact produced"; exit 1 } if ($portable.Count -eq 0) { Write-Error "No portable EXE artifact produced"; exit 1 } + - name: Verify packaged app.asar assets + shell: pwsh + run: | + # FNXC:WindowsDesktopPackaging 2026-07-03-15:40: + # Field report Issue 5: the packaged desktop shipped without preload.js and + # dead-ended on "can't reach the Fusion backend" (preload absence is silent — + # the contextBridge never installs window.fusionShell/fusionAPI). scripts/build.ts + # verifies the pre-package staging tree; this asserts the SHIPPED app.asar itself + # contains the Electron main/preload/renderer entrypoints, since only the packed + # asar reflects what a user installs. + $required = @('dist/main.js', 'dist/preload.js', 'dist/client/index.html') + $unpackedRoots = Get-ChildItem packages/desktop/dist-electron -Directory -Filter 'win*-unpacked' + if ($unpackedRoots.Count -eq 0) { Write-Error "No win-unpacked directory produced"; exit 1 } + foreach ($root in $unpackedRoots) { + $asar = Join-Path $root.FullName 'resources/app.asar' + if (!(Test-Path $asar)) { Write-Error "Missing packaged app.asar: $asar"; exit 1 } + $entries = npx --yes @electron/asar list $asar + if ($LASTEXITCODE -ne 0) { Write-Error "Failed to list app.asar: $asar"; exit 1 } + $normalized = $entries | ForEach-Object { $_.TrimStart('/','\').Replace('\','/') } + foreach ($asset in $required) { + if ($normalized -notcontains $asset) { + Write-Error "app.asar is missing required Electron asset '$asset' in $($root.Name); refusing to ship an incomplete package" + exit 1 + } + } + Write-Host "$($root.Name)/resources/app.asar contains all required Electron assets" + } + # Automated publish is intentionally deferred to FN-5593. # Keep a single artifact; filenames include -x64 / -arm64 so both arches are captured. - name: Upload Windows artifacts diff --git a/packages/desktop/scripts/build.ts b/packages/desktop/scripts/build.ts index 92bee721b3..531ba75c30 100644 --- a/packages/desktop/scripts/build.ts +++ b/packages/desktop/scripts/build.ts @@ -1,7 +1,7 @@ import { build } from "esbuild"; import { cp, mkdir, rm, stat } from "node:fs/promises"; import { join } from "node:path"; -import { buildCore, buildDashboard, buildDashboardClient, buildEngine, packageRoot, stageDesktopDeploy, workspaceRoot } from "./workspace-tools"; +import { buildCore, buildDashboard, buildDashboardClient, buildEngine, desktopDeployDir, packageRoot, stageDesktopDeploy, workspaceRoot } from "./workspace-tools"; const dashboardRoot = join(workspaceRoot, "packages", "dashboard"); const dashboardClientDir = join(dashboardRoot, "dist", "client"); const dashboardRegistryManifestSource = join(dashboardRoot, "src", "registry-manifest.json"); @@ -113,6 +113,42 @@ async function ensureEmbeddedRuntimeBuild(): Promise { await buildEngine(); } +// FNXC:DesktopPackaging 2026-07-03-15:25: +// Guard the packaged Electron closure so a missing preload/main/renderer asset +// fails the build instead of shipping and crashing on a user's machine (field +// report Issue 5: preload was missing from the packaged unpacked layout). +// `preload.js` in particular is silent when absent — the contextBridge never +// installs window.fusionShell/fusionAPI and the app dead-ends on "can't reach +// the Fusion backend". Assert the required files in BOTH the source `dist/` +// (esbuild output) and the staged `deploy/dist/` (what electron-builder packs +// into app.asar), since only the latter reflects what ships. +const REQUIRED_PACKAGED_ASSETS = ["main.js", "preload.js", join("client", "index.html")]; + +async function verifyPackagedArtifacts(): Promise { + console.log("[desktop:build] Verifying required packaged assets are present..."); + const stagedDistDir = join(desktopDeployDir, "dist"); + const roots: Array<{ label: string; dir: string }> = [ + { label: "dist", dir: desktopDistDir }, + { label: "deploy/dist", dir: stagedDistDir }, + ]; + const missing: string[] = []; + for (const { label, dir } of roots) { + for (const asset of REQUIRED_PACKAGED_ASSETS) { + try { + await stat(join(dir, asset)); + } catch { + missing.push(`${label}/${asset}`); + } + } + } + if (missing.length > 0) { + throw new Error( + `Desktop packaging is missing required Electron assets: ${missing.join(", ")}. ` + + `Refusing to ship an incomplete app.asar.`, + ); + } +} + async function main(): Promise { await rm(desktopDistDir, { recursive: true, force: true }); await mkdir(desktopDistDir, { recursive: true }); @@ -128,6 +164,8 @@ async function main(): Promise { // which drops `deduped` subtrees and left the embedded runtime missing deps. await stageDesktopDeploy(); + await verifyPackagedArtifacts(); + console.log("[desktop:build] Desktop build complete"); } diff --git a/packages/desktop/src/__tests__/electron-builder-config.test.ts b/packages/desktop/src/__tests__/electron-builder-config.test.ts index ed3e77246a..0a3d83a543 100644 --- a/packages/desktop/src/__tests__/electron-builder-config.test.ts +++ b/packages/desktop/src/__tests__/electron-builder-config.test.ts @@ -62,9 +62,12 @@ describe("electron-builder desktop config", () => { const packageJsonRaw = await readDesktopFile("package.json"); const packageJson = JSON.parse(packageJsonRaw) as { scripts?: Record }; - expect(packageJson.scripts?.["dist:win"]).toBe("electron-builder --win"); - expect(packageJson.scripts?.["dist:mac"]).toBe("electron-builder --mac"); - expect(packageJson.scripts?.["dist:linux"]).toBe("electron-builder --linux"); + // Scripts package the staged production closure via `--projectDir deploy` + // (see scripts/workspace-tools.ts stageDesktopDeploy), so assert both the + // deploy projectDir and the platform flag rather than a bare invocation. + expect(packageJson.scripts?.["dist:win"]).toBe("electron-builder --projectDir deploy --win"); + expect(packageJson.scripts?.["dist:mac"]).toBe("electron-builder --projectDir deploy --mac"); + expect(packageJson.scripts?.["dist:linux"]).toBe("electron-builder --projectDir deploy --linux"); }); it("keeps required mac and linux targets", async () => { diff --git a/packages/desktop/src/__tests__/main.test.ts b/packages/desktop/src/__tests__/main.test.ts index a8405636de..51723e7dd5 100644 --- a/packages/desktop/src/__tests__/main.test.ts +++ b/packages/desktop/src/__tests__/main.test.ts @@ -328,7 +328,10 @@ describe("main process", () => { const { resolveLocalRuntimeRoot } = await importMainModule(); expect(resolveLocalRuntimeRoot()).toBe("/custom/fusion-home"); - expect(mocks.app.getPath).not.toHaveBeenCalled(); + // FUSION_HOME must satisfy the root without falling back to getPath("home"). + // (Module load calls getPath("userData") for the profile-relocation guard, so + // assert the specific "home" lookup is skipped rather than getPath overall.) + expect(mocks.app.getPath).not.toHaveBeenCalledWith("home"); }); it("initializeApp does not start local runtime for remembered choose mode", async () => { diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts index 5e6641cbc5..bd7a22adad 100644 --- a/packages/desktop/src/__tests__/release-workflow.test.ts +++ b/packages/desktop/src/__tests__/release-workflow.test.ts @@ -19,19 +19,19 @@ describe("desktop release workflow wiring", () => { for (const workflow of [release, testRelease]) { expect(workflow).toContain("build-desktop-windows:"); expect(workflow).toContain("runs-on: windows-latest"); - expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:win|electron-builder --win/); + expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:win|electron-builder[^\n]*--win/); expect(workflow).toContain("name: fusion-desktop-windows"); expect(workflow).toContain("packages/desktop/dist-electron/latest.yml"); expect(workflow).toContain("build-desktop-macos:"); expect(workflow).toContain("runs-on: macos-latest"); - expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:mac|electron-builder --mac/); + expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:mac|electron-builder[^\n]*--mac/); expect(workflow).toContain("name: fusion-desktop-macos"); expect(workflow).toContain("packages/desktop/dist-electron/latest-mac.yml"); expect(workflow).toContain("build-desktop-linux:"); expect(workflow).toContain("runs-on: ubuntu-latest"); - expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:linux|electron-builder --linux/); + expect(workflow).toMatch(/pnpm --filter @fusion\/desktop dist:linux|electron-builder[^\n]*--linux/); expect(workflow).toContain("--x64"); expect(workflow).toContain("--arm64"); expect(workflow).toContain("Fusion-*-linux-arm64.AppImage"); diff --git a/packages/desktop/src/__tests__/user-data-migration.test.ts b/packages/desktop/src/__tests__/user-data-migration.test.ts new file mode 100644 index 0000000000..aa8aed204d --- /dev/null +++ b/packages/desktop/src/__tests__/user-data-migration.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { migratePreviousUserData } from "../user-data-migration.js"; + +/* +FNXC:DesktopUserDataMigration 2026-07-03-15:10: +Verify the one-time profile copy that keeps an upgrading operator's window +geometry/session when userData relocates to ~/.fusion (field report Issue 8): +it copies a populated previous profile into an absent/empty new dir exactly once, +and never overwrites an already-migrated profile, an empty source, or the same path. +*/ +describe("migratePreviousUserData", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fusion-userdata-mig-")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const seed = (dir: string, file: string, contents: string): void => { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, file), contents); + }; + + it("copies a populated previous profile into an absent new dir", () => { + const previous = join(root, "old"); + const next = join(root, "new"); + seed(previous, "window-state.json", "{\"x\":10}"); + + expect(migratePreviousUserData(previous, next)).toBe(true); + expect(readFileSync(join(next, "window-state.json"), "utf-8")).toBe("{\"x\":10}"); + // Copy, not move: the source is left intact for downgrades/partial failures. + expect(readFileSync(join(previous, "window-state.json"), "utf-8")).toBe("{\"x\":10}"); + }); + + it("does not overwrite a new dir that already has data", () => { + const previous = join(root, "old"); + const next = join(root, "new"); + seed(previous, "session.json", "old"); + seed(next, "session.json", "already-migrated"); + + expect(migratePreviousUserData(previous, next)).toBe(false); + expect(readFileSync(join(next, "session.json"), "utf-8")).toBe("already-migrated"); + }); + + it("treats an empty new dir as eligible for migration", () => { + const previous = join(root, "old"); + const next = join(root, "new"); + seed(previous, "session.json", "restore-me"); + mkdirSync(next, { recursive: true }); // exists but empty + + expect(migratePreviousUserData(previous, next)).toBe(true); + expect(readFileSync(join(next, "session.json"), "utf-8")).toBe("restore-me"); + }); + + it("no-ops when there is no previous profile", () => { + const previous = join(root, "missing"); + const next = join(root, "new"); + + expect(migratePreviousUserData(previous, next)).toBe(false); + }); + + it("no-ops when the previous profile exists but is empty", () => { + const previous = join(root, "old"); + const next = join(root, "new"); + mkdirSync(previous, { recursive: true }); + + expect(migratePreviousUserData(previous, next)).toBe(false); + }); + + it("no-ops when previous and new paths are identical", () => { + const same = join(root, "same"); + seed(same, "session.json", "keep"); + + expect(migratePreviousUserData(same, same)).toBe(false); + expect(readFileSync(join(same, "session.json"), "utf-8")).toBe("keep"); + }); +}); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 56a7b3eb97..f4ef93e6a8 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -2,12 +2,22 @@ import { app, BrowserWindow, nativeImage, screen, Tray } from "electron"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; +import { migratePreviousUserData } from "./user-data-migration.js"; /* FNXC:DesktopUserDataIsolation 2026-07-03-14:40: Isolate Electron desktop user-data/cache/crash artifacts under ~/.fusion/desktop-user-data so the packaged desktop does not share (or collide with) the default per-app Chromium profile across installs and Fusion versions (field report Issue 8). setPath must run before app "ready"; once locked it throws, which is non-fatal here. + +FNXC:DesktopUserDataMigration 2026-07-03-15:10: +Migrate the previous default profile into the new location BEFORE `setPath` overrides it, so upgrading operators keep their window geometry/session. `app.getPath("userData")` returns the default `/` path until overridden, so capture it first. See user-data-migration.ts for the one-time-copy gating. */ const fusionUserDataDir = join(os.homedir(), ".fusion", "desktop-user-data"); + +const migratedUserData = migratePreviousUserData(app.getPath("userData"), fusionUserDataDir); +if (migratedUserData) { + console.log(`[desktop/main] Migrated previous desktop profile into ${fusionUserDataDir}`); +} + try { app.commandLine.appendSwitch("user-data-dir", fusionUserDataDir); app.setPath("userData", fusionUserDataDir); diff --git a/packages/desktop/src/user-data-migration.ts b/packages/desktop/src/user-data-migration.ts new file mode 100644 index 0000000000..9c1e69ed0e --- /dev/null +++ b/packages/desktop/src/user-data-migration.ts @@ -0,0 +1,32 @@ +import { cpSync, existsSync, readdirSync } from "node:fs"; + +/* +FNXC:DesktopUserDataMigration 2026-07-03-15:10: +Relocating the Electron desktop profile to ~/.fusion/desktop-user-data (field report Issue 8) would silently orphan an existing operator's window geometry, sign-in session, and local storage under the OLD default Chromium profile (`/`). This performs a one-time COPY of the previous default profile into the new location, gated so it runs only on the first launch after upgrade: the new dir must be absent/empty and the old dir must exist, be non-empty, and be distinct. Copy (not move) so a failed/partial migration or a downgrade still finds the original profile intact. Best-effort: any failure returns false and the caller falls back to a fresh profile rather than blocking startup. +*/ + +function isMissingOrEmptyDir(dir: string): boolean { + try { + return readdirSync(dir).length === 0; + } catch { + // ENOENT (or unreadable) → treat as missing so we do not block on it. + return true; + } +} + +/** + * One-time copy of a previous Electron userData profile into `newDir`. + * @returns `true` if a migration copy was performed, `false` otherwise (already + * migrated, nothing to migrate, same path, or the copy failed). + */ +export function migratePreviousUserData(previousDir: string, newDir: string): boolean { + try { + if (previousDir === newDir) return false; + if (!isMissingOrEmptyDir(newDir)) return false; // already migrated / has its own data + if (!existsSync(previousDir) || isMissingOrEmptyDir(previousDir)) return false; + cpSync(previousDir, newDir, { recursive: true }); + return true; + } catch { + return false; + } +} diff --git a/reports/desktop-release-issues-2026-07-03.md b/reports/desktop-release-issues-2026-07-03.md index 3c71d701cc..985108c5e4 100644 --- a/reports/desktop-release-issues-2026-07-03.md +++ b/reports/desktop-release-issues-2026-07-03.md @@ -10,6 +10,26 @@ This document collects the issues we found while trying to run the official Fusi --- +## Resolution status (PR #1883) + +| # | Issue | Status | +|---|-------|--------| +| 1 | `electron` only a devDependency | **Fixed** — `electron` added to `@runfusion/fusion` runtime deps; lockfile synced. | +| 2 | Ancestor-dir walk crashes on unrelated JSON | **Already handled** — the desktop launcher uses `process.cwd()` (CLI) / `$HOME` (Electron main), never an ancestor walk (`desktop.ts:175`, `main.ts` `resolveLocalRuntimeRoot`), and every JSON parse on the shared discovery path is already `try/catch`-guarded, so unrelated JSON no longer throws. | +| 3 | Manage Projects opens Settings | **Fixed** — `handleViewAllProjects` now resets `taskView` to `command-center`. | +| 4 | Windows Terminal "Help" dialogs | **Fixed** — frontend auto-create of the first terminal tab is skipped on Windows. | +| 5 | Packaged build can miss `preload`/assets | **Fixed** — `scripts/build.ts` now verifies `main.js`, `preload.js`, and `client/index.html` exist in both `dist/` and the staged `deploy/dist/` before packaging, failing the build otherwise. (In this repo the preload ships as `dist/preload.js` inside `app.asar`, not `preload.cjs`.) | +| 6 | Desktop port drift / collision | **Already handled** — the embedded runtime binds an ephemeral port (`app.listen(0)`, `desktop.ts:78`), so fixed-port collision is structurally impossible; the CLI passes it via `FUSION_SERVER_PORT` and the desktop reuses it instead of double-binding (Issue 9), and a single-instance lock (`deep-link.ts`) quits a duplicate window. A fixed 9119/8643 would *reintroduce* collisions, so we deliberately did not pin one. | +| 7 | GPU/sandbox instability on Windows | **Fixed** — GPU/sandbox-disabling flags applied on Windows only (`os.platform() === "win32"`); macOS/Linux keep hardware acceleration and the sandbox. | +| 8 | User-data not isolated | **Fixed** — desktop profile relocated under `~/.fusion/desktop-user-data`, with a one-time copy migrating an existing operator's previous profile (window geometry/session) so upgrades don't lose state. | +| 9 | Desktop doesn't reuse a running server | **Fixed** — when `FUSION_SERVER_PORT` is set the desktop attaches to the CLI's dashboard instead of spawning an embedded runtime. | + +Recommendation #2 (verify packaged Windows layout before publishing) is now **enforced in CI**: `desktop-windows.yml` asserts the shipped `app.asar` contains `dist/main.js`, `dist/preload.js`, and `dist/client/index.html`, on top of `scripts/build.ts`'s pre-package staging check. + +Remaining as future team work (deliberately not attempted here): recommendation #1's full GUI-launch smoke (launch `Fusion.exe` and assert the window is visible/responsive) — reliably asserting a rendered Electron window on a CI runner is flaky, which the project's standing anti-flaky rule forbids adding; and the port/process auditing docs for Issue 6. + +--- + ## Issue 1: `fusion desktop` fails to launch on Windows 0.52.0 ### Symptom