fix(desktop): migrate userData on relocation, verify packaged assets, harden Windows CI
- Add a one-time copy of the previous default Electron profile into ~/.fusion/desktop-user-data (user-data-migration.ts) so upgrading operators keep window geometry/session instead of starting fresh (report Issue 8). - Verify main.js/preload.js/client assets in scripts/build.ts (pre-package staging) and in the packaged app.asar via desktop-windows.yml, so an incomplete package fails the build/CI instead of dead-ending at runtime with a silent missing preload (report Issue 5, recommendation #2). - Fix two pre-existing stale workflow-assertion tests that expected a bare 'electron-builder --win' after the scripts moved to '--projectDir deploy'. - Update the field report with a per-issue resolution matrix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
28
.github/workflows/desktop-windows.yml
vendored
28
.github/workflows/desktop-windows.yml
vendored
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await rm(desktopDistDir, { recursive: true, force: true });
|
||||
await mkdir(desktopDistDir, { recursive: true });
|
||||
@@ -128,6 +164,8 @@ async function main(): Promise<void> {
|
||||
// which drops `deduped` subtrees and left the embedded runtime missing deps.
|
||||
await stageDesktopDeploy();
|
||||
|
||||
await verifyPackagedArtifacts();
|
||||
|
||||
console.log("[desktop:build] Desktop build complete");
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,12 @@ describe("electron-builder desktop config", () => {
|
||||
const packageJsonRaw = await readDesktopFile("package.json");
|
||||
const packageJson = JSON.parse(packageJsonRaw) as { scripts?: Record<string, string> };
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
84
packages/desktop/src/__tests__/user-data-migration.test.ts
Normal file
84
packages/desktop/src/__tests__/user-data-migration.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<appData>/<productName>` 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);
|
||||
|
||||
32
packages/desktop/src/user-data-migration.ts
Normal file
32
packages/desktop/src/user-data-migration.ts
Normal file
@@ -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 (`<appData>/<productName>`). 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user