FN-7360: fix Windows desktop runtime packaging

Fix packaged Windows desktop startup by preserving Node-safe runtime dependencies and dashboard registry assets.

- Externalize Electron main/preload npm packages so CommonJS updater dependencies load natively.
- Load the dashboard plugin registry manifest through file IO and copy it into dashboard server dist from all build paths.
- Split Windows NSIS and portable artifact names and verify required Electron runtime resources in CI.
- Cover the packaging/runtime invariants with desktop and dashboard regression tests.

Files changed:
 .changeset/fn-7360-desktop-windows.md              |  7 +++
 .github/workflows/desktop-windows.yml              | 21 ++++++++
 packages/dashboard/package.json                    |  2 +-
 .../src/__tests__/plugin-registry-dist.test.ts     | 32 ++++++++++++
 .../src/__tests__/routes-plugin-registry.test.ts   | 10 ++--
 packages/dashboard/src/plugin-routes.ts            | 27 +++++++++-
 packages/desktop/README.md                         |  5 +-
 packages/desktop/electron-builder.yml              |  9 ++++
 packages/desktop/scripts/build.ts                  | 29 ++++++++--
 packages/desktop/scripts/workspace-tools.ts        |  4 ++
 .../desktop/src/__tests__/build-bundling.test.ts   | 61 ++++++++++++++++++++++
 .../src/__tests__/electron-builder-config.test.ts  | 15 ++++++
 packages/desktop/src/__tests__/ipc.test.ts         | 15 ++++++
 .../desktop/src/__tests__/local-runtime.test.ts    | 20 +++++++
 14 files changed, 247 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7360

Fusion-Task-Lineage: a2d88130-c24f-43e2-932a-1e174b6afa35

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 08:01:59 -07:00
parent a65633d878
commit 1f3a15ec1c
14 changed files with 247 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Windows desktop startup failures in packaged builds.
category: fix
dev: Externalizes Electron updater CJS dependencies, loads the dashboard registry manifest through Node-safe file IO, and separates NSIS/portable Windows artifacts.

View File

@@ -58,6 +58,27 @@ jobs:
if ($sig.Status -ne 'Valid') { Write-Error "Signature invalid: $($exe.Name) ($($sig.Status))"; exit 1 }
}
- name: Verify Windows runtime resources
shell: pwsh
run: |
# FNXC:WindowsDesktopPackaging 2026-07-01-08:08:
# The Windows app must install Electron's root .pak runtime resources;
# missing chrome_100_percent.pak, chrome_200_percent.pak, or resources.pak
# leaves Fusion.exe unable to start even when the NSIS installer succeeds.
$requiredResources = @('chrome_100_percent.pak', 'chrome_200_percent.pak', 'resources.pak')
$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) {
foreach ($resource in $requiredResources) {
$resourcePath = Join-Path $root.FullName $resource
if (!(Test-Path $resourcePath)) { Write-Error "Missing Electron runtime resource: $resourcePath"; exit 1 }
}
}
$nsis = Get-ChildItem packages/desktop/dist-electron -Filter 'Fusion-*-win-*.exe' | Where-Object { $_.Name -notmatch '-portable\.exe$' }
$portable = Get-ChildItem packages/desktop/dist-electron -Filter 'Fusion-*-win-*-portable.exe'
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 }
# 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

View File

@@ -59,7 +59,7 @@
"gen:locales": "node scripts/sync-locales.mjs",
"verify:locale-chunks": "node scripts/assert-locale-chunks.mjs",
"prebuild": "node scripts/sync-locales.mjs",
"build": "vite build && tsc",
"build": "vite build && tsc && node -e \"require('node:fs').copyFileSync('src/registry-manifest.json','dist/registry-manifest.json')\"",
"prebuild:client": "node scripts/sync-locales.mjs",
"build:client": "vite build",
"predev:serve": "node scripts/sync-locales.mjs",

View File

@@ -0,0 +1,32 @@
// @vitest-environment node
import { access, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dashboardRoot = path.resolve(__dirname, "../..");
async function readDashboardFile(relativePath: string): Promise<string> {
return readFile(path.join(dashboardRoot, relativePath), "utf-8");
}
describe("plugin registry production output", () => {
it("does not emit a Node 22-invalid static JSON import for registry-manifest.json", async () => {
const pluginRoutesDist = await readDashboardFile("dist/plugin-routes.js");
expect(pluginRoutesDist).not.toMatch(/import\s+\w+\s+from\s+["']\.\/registry-manifest\.json["'];?/);
expect(pluginRoutesDist).toContain('new URL("./registry-manifest.json", import.meta.url)');
expect(pluginRoutesDist).toContain("JSON.parse");
});
it("copies a readable registry manifest beside the emitted dashboard server files", async () => {
const manifestPath = path.join(dashboardRoot, "dist/registry-manifest.json");
await expect(access(manifestPath)).resolves.toBeUndefined();
const manifest = JSON.parse(await readFile(manifestPath, "utf-8")) as { plugins?: unknown };
expect(Array.isArray(manifest.plugins)).toBe(true);
});
});

View File

@@ -4,8 +4,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import express from "express";
import type { PluginInstallation, PluginStore } from "@fusion/core";
import registryManifest from "../registry-manifest.json";
import { buildRegistryPluginEntries, createPluginRouter } from "../plugin-routes.js";
import { buildRegistryPluginEntries, createPluginRouter, loadRegistryManifest } from "../plugin-routes.js";
import { get as performGet } from "../test-request.js";
import * as projectStoreResolver from "../project-store-resolver.js";
@@ -68,9 +67,12 @@ describe("GET /api/plugins/registry", () => {
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry");
expect(res.status).toBe(200);
const registryManifest = await loadRegistryManifest();
const manifestPlugins = Array.isArray(registryManifest.plugins) ? registryManifest.plugins : [];
// Registry currently includes Agent Browser as metadata-only plus 3 discovery-only partner/plugin ideas.
expect(registryManifest.plugins.filter((plugin) => !plugin.path)).toHaveLength(4);
expect((res.body as { plugins: unknown[] }).plugins).toHaveLength(registryManifest.plugins.length);
expect(manifestPlugins.filter((plugin) => typeof plugin === "object" && plugin && !("path" in plugin))).toHaveLength(4);
expect((res.body as { plugins: unknown[] }).plugins).toHaveLength(manifestPlugins.length);
});
it("filters by q across searchable text", async () => {

View File

@@ -19,7 +19,6 @@ import { Router, type Request, type Response } from "express";
import { access, stat, readFile } from "node:fs/promises";
import { join, isAbsolute, dirname, basename } from "node:path";
import { emitPluginCustomSseEvent } from "./sse.js";
import registryManifest from "./registry-manifest.json";
import type {
PluginInstallation,
PluginLoader,
@@ -80,6 +79,31 @@ interface RegistryManifestShape {
plugins?: unknown;
}
const registryManifestUrl = new URL("./registry-manifest.json", import.meta.url);
let cachedRegistryManifest: RegistryManifestShape | null = null;
export async function loadRegistryManifest(): Promise<RegistryManifestShape> {
if (cachedRegistryManifest) {
return cachedRegistryManifest;
}
try {
const raw = await readFile(registryManifestUrl, "utf-8");
cachedRegistryManifest = JSON.parse(raw) as RegistryManifestShape;
return cachedRegistryManifest;
} catch (error) {
// FNXC:PluginRegistry 2026-07-01-07:45:
// Desktop local mode imports the dashboard server under Node 22+, where static
// JSON imports require attributes that TypeScript did not emit. Read the
// registry manifest as data at request time and degrade to an empty registry
// when the packaged manifest is missing or malformed so startup never fails at
// module load with ERR_IMPORT_ATTRIBUTE_MISSING.
console.warn("[dashboard/plugins] Registry manifest unavailable; serving an empty plugin registry", error);
cachedRegistryManifest = {};
return cachedRegistryManifest;
}
}
function normalizeRegistryManifestEntries(manifest: RegistryManifestShape): RegistryManifestEntry[] {
if (!manifest || !Array.isArray(manifest.plugins)) {
return [];
@@ -330,6 +354,7 @@ export function createPluginRouter(
: undefined;
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
const store = scopedStore?.getPluginStore?.() ?? pluginStore;
const registryManifest = await loadRegistryManifest();
const plugins = await buildRegistryPluginEntries(registryManifest, store, { q, category });
res.json({ plugins });
}));

View File

@@ -358,7 +358,10 @@ 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 artifacts: `Fusion-<version>-win-x64.exe` and `Fusion-<version>-win-arm64.exe` (both NSIS + portable variants) in `packages/desktop/dist-electron/`
- 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.

View File

@@ -142,11 +142,20 @@ win:
publisherName: Fusion
nsis:
# FNXC:WindowsDesktopPackaging 2026-07-01-08:02:
# NSIS is the installable Windows artifact and must keep the 0.50-style
# Fusion-<version>-win-<arch>.exe name so updater/install docs remain stable.
# The portable target writes a separate -portable EXE below to avoid colliding
# with or overwriting the NSIS installer during x64/arm64 packaging.
artifactName: "${productName}-${version}-${os}-${arch}.${ext}"
oneClick: false
perMachine: false
allowElevation: false
allowToChangeInstallationDirectory: true
portable:
artifactName: "${productName}-${version}-${os}-${arch}-portable.${ext}"
linux:
category: Development
target:

View File

@@ -1,8 +1,11 @@
import { build } from "esbuild";
import { cp, mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { buildDashboardClient, packageRoot, workspaceRoot } from "./workspace-tools";
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
import { buildDashboard, buildDashboardClient, packageRoot, workspaceRoot } from "./workspace-tools";
const dashboardRoot = join(workspaceRoot, "packages", "dashboard");
const dashboardClientDir = join(dashboardRoot, "dist", "client");
const dashboardRegistryManifestSource = join(dashboardRoot, "src", "registry-manifest.json");
const dashboardRegistryManifestDist = join(dashboardRoot, "dist", "registry-manifest.json");
const desktopDistDir = join(packageRoot, "dist");
const desktopClientDistDir = join(desktopDistDir, "client");
// FNXC:DesktopBuild 2026-06-25-09:45:
@@ -23,7 +26,15 @@ const mainExternals = sharedExternals;
const preloadExternals = sharedExternals;
async function ensureDashboardBuild(): Promise<void> {
console.log("[desktop:build] Building dashboard client...");
// FNXC:DesktopBuild 2026-07-01-11:35:
// Windows release packaging invokes only `@fusion/desktop build` before electron-builder.
// Build the dashboard server dist and copy registry-manifest.json here so the packaged
// embedded runtime never depends on a separate `@fusion/dashboard build` workflow step.
console.log("[desktop:build] Building dashboard server runtime...");
await buildDashboard();
await cp(dashboardRegistryManifestSource, dashboardRegistryManifestDist);
console.log("[desktop:build] Building dashboard client for file:// desktop loading...");
await buildDashboardClient();
try {
@@ -31,6 +42,12 @@ async function ensureDashboardBuild(): Promise<void> {
} catch {
throw new Error(`Dashboard client assets not found: ${dashboardClientDir}`);
}
try {
await stat(dashboardRegistryManifestDist);
} catch {
throw new Error(`Dashboard registry manifest not found: ${dashboardRegistryManifestDist}`);
}
}
async function buildElectronEntrypoints(): Promise<void> {
@@ -45,6 +62,12 @@ async function buildElectronEntrypoints(): Promise<void> {
platform: "node",
target: "node22",
sourcemap: true,
// FNXC:DesktopBuild 2026-07-01-07:31:
// Windows Electron main output is ESM, but electron-updater loads CJS deps
// such as fs-extra/graceful-fs that dynamically require built-ins. Keep all
// npm packages external so Node/Electron evaluates those CJS modules natively
// instead of esbuild emitting a __require("fs") trap in dist/main.js.
packages: "external",
external: mainExternals,
logLevel: "info",
}),

View File

@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { cp } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -64,6 +65,9 @@ export async function buildDashboard(): Promise<void> {
await buildDashboardRuntimePlugins();
await runWorkspaceBin("vite", ["build"], dashboardRoot);
await runWorkspaceBin("tsc", [], dashboardRoot);
// FNXC:DesktopBuild 2026-07-01-11:45:
// Desktop release and test paths call this helper directly instead of the dashboard package script, so copy the Node-read registry manifest beside server dist here as the shared build invariant.
await cp(resolve(dashboardRoot, "src", "registry-manifest.json"), resolve(dashboardRoot, "dist", "registry-manifest.json"));
}
export async function buildDashboardClient(): Promise<void> {

View File

@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const desktopRoot = path.resolve(__dirname, "../..");
async function readDesktopFile(relativePath: string): Promise<string> {
return readFile(path.join(desktopRoot, relativePath), "utf-8");
}
describe("desktop Electron main bundling", () => {
it("builds dashboard server artifacts and registry manifest from the desktop release build path", async () => {
const buildScript = await readDesktopFile("scripts/build.ts");
expect(buildScript).toContain("buildDashboard()");
expect(buildScript).toContain("dashboardRegistryManifestSource");
expect(buildScript).toContain("dashboardRegistryManifestDist");
expect(buildScript).toContain("await cp(dashboardRegistryManifestSource, dashboardRegistryManifestDist)");
});
it("externalizes production main-process packages so updater CJS deps are not bundled into ESM", async () => {
const buildScript = await readDesktopFile("scripts/build.ts");
const mainBuildBlock = buildScript.match(/entryPoints: \[join\(packageRoot, "src", "main\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0];
expect(mainBuildBlock).toBeDefined();
expect(mainBuildBlock).toContain('format: "esm"');
expect(mainBuildBlock).toContain('platform: "node"');
expect(mainBuildBlock).toContain('packages: "external"');
expect(mainBuildBlock).toContain("external: mainExternals");
const preloadBuildBlock = buildScript.match(/entryPoints: \[join\(packageRoot, "src", "preload\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0];
expect(preloadBuildBlock).toBeDefined();
expect(preloadBuildBlock).toContain('format: "cjs"');
expect(preloadBuildBlock).toContain('packages: "external"');
});
it("keeps development main-process bundling aligned with the production package-external invariant", async () => {
const devScript = await readDesktopFile("scripts/dev.ts");
const mainBuildBlock = devScript.match(/entryPoints: \[join\(packageRoot, "src", "main\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0];
expect(mainBuildBlock).toBeDefined();
expect(mainBuildBlock).toContain('format: "esm"');
expect(mainBuildBlock).toContain('packages: "external"');
expect(mainBuildBlock).toContain('external: ["electron"]');
});
it("keeps known updater CommonJS dependencies available as packaged runtime files", async () => {
const builderConfig = await readDesktopFile("electron-builder.yml");
for (const runtimeDependency of [
"node_modules/electron-updater/**/*",
"node_modules/fs-extra/**/*",
"node_modules/graceful-fs/**/*",
]) {
expect(builderConfig).toContain(`- ${runtimeDependency}`);
}
});
});

View File

@@ -35,8 +35,10 @@ describe("electron-builder desktop config", () => {
expect(extractArchValues(nsisArchMatch![1])).toEqual(["arm64", "x64"]);
expect(extractArchValues(portableArchMatch![1])).toEqual(["arm64", "x64"]);
expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}\.\$\{ext\}"/m);
expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?oneClick:\s*false/m);
expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?allowToChangeInstallationDirectory:\s*true/m);
expect(builderConfig).toMatch(/portable:\s*[\s\S]*?artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}-portable\.\$\{ext\}"/m);
expect(builderConfig).toMatch(/artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}\.\$\{ext\}"/m);
expect(builderConfig).toMatch(/appId:\s*com\.gsxdsm\.fusion\.desktop/m);
@@ -107,6 +109,14 @@ describe("electron-builder desktop config", () => {
expect(linuxArchByTarget.get("tar.gz")).toEqual(["arm64", "x64"]);
});
it("does not exclude Electron runtime pak resources from Windows unpacked output", async () => {
const builderConfig = await readDesktopFile("electron-builder.yml");
for (const requiredPak of ["chrome_100_percent.pak", "chrome_200_percent.pak", "resources.pak"]) {
expect(builderConfig).not.toMatch(new RegExp(`!.*${requiredPak.replaceAll(".", "\\.")}`));
}
});
it("packages @fusion/core runtime dependencies used during desktop startup", async () => {
const builderConfig = await readDesktopFile("electron-builder.yml");
const requiredRuntimeDependencyGlobs = [
@@ -175,6 +185,11 @@ describe("desktop windows workflow signing guards", () => {
expect(workflow).toContain("CSC_KEY_PASSWORD:");
expect(workflow).toContain("WINDOWS_CERTIFICATE_BASE64 != ''");
expect(workflow).toContain("Get-AuthenticodeSignature");
expect(workflow).toContain("Verify Windows runtime resources");
expect(workflow).toContain("chrome_100_percent.pak");
expect(workflow).toContain("chrome_200_percent.pak");
expect(workflow).toContain("resources.pak");
expect(workflow).toContain("Fusion-*-win-*-portable.exe");
expect(workflow).toContain("intentionally deferred");
});
});

View File

@@ -169,6 +169,21 @@ describe("ipc handlers", () => {
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
});
it("shell:setDesktopMode propagates local runtime startup errors", async () => {
const onDesktopModeChange = vi.fn(async () => {
throw new Error("dashboard import failed: ERR_IMPORT_ATTRIBUTE_MISSING");
});
const { window } = await registerHandlers({ onDesktopModeChange, getRuntimeStatus: () => ({ source: "embedded-local", state: "error", error: "dashboard import failed: ERR_IMPORT_ATTRIBUTE_MISSING" }) });
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
await expect(handler?.({}, "local")).rejects.toThrow("ERR_IMPORT_ATTRIBUTE_MISSING");
expect(mocks.writeShellSettings).toHaveBeenCalledWith(
expect.objectContaining({ desktopMode: "local", hasCompletedModeSelection: true }),
);
expect(window.webContents.send).not.toHaveBeenCalledWith("shell:state", expect.any(Object));
});
it("desktop launch mode handlers return mode/context and validate payload", async () => {
const getDesktopLaunchContext = vi.fn(() => ({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" }));
const onDesktopLaunchModeChange = vi.fn(async () => undefined);

View File

@@ -114,6 +114,26 @@ describe("LocalRuntimeManager", () => {
});
});
it("surfaces dashboard import failures instead of replacing them with a generic timeout", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const importError = new TypeError("ERR_IMPORT_ATTRIBUTE_MISSING: registry-manifest.json requires an import attribute");
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
createDashboardServer: async () => {
throw importError;
},
});
await expect(manager.startLocal()).rejects.toThrow("ERR_IMPORT_ATTRIBUTE_MISSING");
expect(store.close).toHaveBeenCalledTimes(1);
expect(manager.getStatus()).toMatchObject({
source: "embedded-local",
state: "error",
error: "ERR_IMPORT_ATTRIBUTE_MISSING: registry-manifest.json requires an import attribute",
});
});
it("stopLocal is idempotent and no-op when inactive", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const server = new FakeServer(4545);