fix(desktop): package Windows via NSIS instead of Squirrel (#403)

* fix(desktop): package Windows via NSIS instead of Squirrel (#401)

Squirrel.Windows is a poor fit: per-user install only, no custom install
directory, no proper add/remove-programs uninstaller, and fragile updates.
Replace it with a real NSIS installer (per-user or per-machine, custom
install dir, uninstaller), matching recordly's working Windows setup.

Electron Forge ships no first-party NSIS maker, so add a thin MakerBase
subclass (makers/maker-nsis.ts) that bridges to electron-builder's
buildForge, the same engine electron-builder uses, scoped to win32. The
maker exposes the NSIS knobs the issue calls for (oneClick:false,
allowToChangeInstallationDirectory, per-machine) and defaults to an
assisted installer.

- forge.config.ts: drop maker-squirrel, add the NSIS maker instance.
- testing-build.yml: target "nsis"; smoke-install via /S under out/make;
  drop the Squirrel-specific log capture.
- Rename the package "agent-orchestrator-frontend" -> "agent-orchestrator":
  this repo is the full app, not just a frontend. User-facing naming was
  already "Agent Orchestrator" (productName) / agent-orchestrator.exe.

Deferred (per issue, separate follow-ups): bundling zellij.exe so a fresh
Windows install needs no manual zellij, an actionable "zellij not found"
error at session-create, and Windows code-signing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(desktop): disable electron-builder publish + drop electron-squirrel-startup

CI fix: the NSIS maker's electron-builder run inferred a GitHub publish
target from package.json `repository` and tried to upload (to emit
auto-update info), failing with "GitHub Personal Access Token is not set".
Forge owns publishing (the workflow uploads via `gh release`), so set
`config.publish = null` to disable electron-builder's upload entirely.

Also remove `electron-squirrel-startup`: it only handled Squirrel.Windows
install/update hooks (--squirrel-* flags) and is dead weight under NSIS.
Drop the dependency, its import, the startup quit-block, the whenReady
guard, and the type shim. The EPIPE std-stream guard stays (it covers any
windowless Windows GUI launch, e.g. from a shortcut).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: gitignore electron-builder's builder-debug.yml dump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-23 20:44:30 +05:30 committed by GitHub
parent f15021c19d
commit e970f72d3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1804 additions and 161 deletions

View File

@ -6,7 +6,7 @@ name: Desktop testing build
# #
# Per OS the current electron-forge makers produce: # Per OS the current electron-forge makers produce:
# - macOS → .zip (the .dmg maker is a follow-up) # - macOS → .zip (the .dmg maker is a follow-up)
# - Windows → Setup.exe + .nupkg + RELEASES (Squirrel) # - Windows → NSIS installer (.exe)
# - Linux → .deb and .rpm # - Linux → .deb and .rpm
# #
# Each OS builds on its own native runner because build-daemon.mjs compiles the # Each OS builds on its own native runner because build-daemon.mjs compiles the

View File

@ -6,7 +6,7 @@ name: Testing build (all platforms)
# #
# Per OS: # Per OS:
# Linux -> .deb # Linux -> .deb
# Windows -> Setup.exe (+ .nupkg / RELEASES) # Windows -> NSIS installer (.exe)
# macOS -> .zip (arm64; dmg + signing are follow-ups) # macOS -> .zip (arm64; dmg + signing are follow-ups)
# #
# Unsigned: macOS is quarantined/Gatekeeper-blocked once downloaded # Unsigned: macOS is quarantined/Gatekeeper-blocked once downloaded
@ -29,7 +29,9 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: "@electron-forge/maker-deb" target: "@electron-forge/maker-deb"
- os: windows-latest - os: windows-latest
target: "@electron-forge/maker-squirrel" # Our custom NSIS maker's `name` (see makers/maker-nsis.ts); forge
# `--targets` matches the configured maker instance by this name.
target: "nsis"
- os: macos-latest - os: macos-latest
target: "@electron-forge/maker-zip" target: "@electron-forge/maker-zip"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@ -58,10 +60,10 @@ jobs:
# `npm run make` keeps the premake daemon build; --targets restricts to this # `npm run make` keeps the premake daemon build; --targets restricts to this
# platform's maker. # platform's maker.
run: npm run make -- --targets ${{ matrix.target }} run: npm run make -- --targets ${{ matrix.target }}
# Smoke-install the Squirrel installer on a clean, native x64 Windows runner. # Smoke-install the NSIS installer on a clean, native x64 Windows runner.
# This is a build-vs-host verdict: if it installs here it proves the artifact # This is a build-vs-host verdict: if it installs here it proves the artifact
# is good and a failing user machine is host-side (AV/disk/signing); if it # is good and a failing user machine is host-side (AV/disk/signing); if it
# fails here the build/Squirrel config is wrong. continue-on-error so a failed # fails here the build/NSIS config is wrong. continue-on-error so a failed
# install never blocks publishing the artifacts. The runner has no real-time # install never blocks publishing the artifacts. The runner has no real-time
# AV blocking, so a clean install here does NOT prove SmartScreen/Defender # AV blocking, so a clean install here does NOT prove SmartScreen/Defender
# won't reject the unsigned binaries on end-user machines. # won't reject the unsigned binaries on end-user machines.
@ -71,33 +73,26 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
shell: pwsh shell: pwsh
run: | run: |
$setup = Get-ChildItem -Path out/make/squirrel.windows -Recurse -Filter '*Setup.exe' | Select-Object -First 1 $setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' |
if (-not $setup) { Write-Host '::error::no Setup.exe produced under out/make/squirrel.windows'; exit 1 } Where-Object { $_.Name -like '*Setup*' } | Select-Object -First 1
Write-Host "Running $($setup.FullName) --silent" if (-not $setup) { $setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' | Select-Object -First 1 }
$proc = Start-Process -FilePath $setup.FullName -ArgumentList '--silent' -PassThru -Wait if (-not $setup) { Write-Host '::error::no NSIS installer (.exe) produced under out/make'; exit 1 }
Write-Host "Setup.exe exit code: $($proc.ExitCode)" Write-Host "Running $($setup.FullName) /S (silent)"
$installDir = Join-Path $env:LOCALAPPDATA 'AgentOrchestrator' # electron-builder NSIS (assisted installer): /S installs silently.
# Update.exe writes SquirrelSetup.log into the app root dir; the Setup.exe $proc = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -PassThru -Wait
# bootstrapper only logs to SquirrelTemp for the earliest extraction stage. Write-Host "Installer exit code: $($proc.ExitCode)"
# Grab whichever exists and copy it into the workspace for upload. # Per-user assisted install lands under %LOCALAPPDATA%\Programs; a
$log = @( # per-machine install would land under Program Files.
(Join-Path $installDir 'SquirrelSetup.log'), $installDir = @(
(Join-Path $env:LOCALAPPDATA 'SquirrelTemp\SquirrelSetup.log') (Join-Path $env:LOCALAPPDATA 'Programs\Agent Orchestrator'),
(Join-Path ${env:ProgramFiles} 'Agent Orchestrator')
) | Where-Object { Test-Path $_ } | Select-Object -First 1 ) | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($log) { Copy-Item $log squirrel-setup.log; Write-Host "captured log: $log" } else { Write-Host '::warning::no SquirrelSetup.log found in install dir or SquirrelTemp' } if ($installDir) {
if (Test-Path $installDir) {
Write-Host "INSTALL OK: $installDir created" Write-Host "INSTALL OK: $installDir created"
Get-ChildItem $installDir | Select-Object Name | Format-Table -AutoSize Get-ChildItem $installDir | Select-Object Name | Format-Table -AutoSize
} else { } else {
Write-Host "::warning::INSTALL FAILED: $installDir was not created" Write-Host "::warning::INSTALL: no known install dir found (checked LOCALAPPDATA\Programs and Program Files)"
} }
- name: Upload SquirrelSetup.log
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: squirrel-setup-log-${{ github.sha }}
path: frontend/squirrel-setup.log
if-no-files-found: warn
- name: Publish to a 0.0.0-testing-<sha> prerelease - name: Publish to a 0.0.0-testing-<sha> prerelease
shell: bash shell: bash
env: env:
@ -114,7 +109,7 @@ jobs:
# "already exists" which is fine (|| true). Distinct asset names + --clobber # "already exists" which is fine (|| true). Distinct asset names + --clobber
# make uploads idempotent across re-runs. # make uploads idempotent across re-runs.
gh release create "$TAG" --prerelease --target "$GITHUB_SHA" --title "$TAG" \ gh release create "$TAG" --prerelease --target "$GITHUB_SHA" --title "$TAG" \
--notes "Unsigned testing build (Linux .deb / Windows Setup.exe / macOS .zip). Not signed; for testing only." \ --notes "Unsigned testing build (Linux .deb / Windows NSIS .exe / macOS .zip). Not signed; for testing only." \
|| true || true
# NUL-delimited to survive spaces in the app name ("Agent Orchestrator-..."). # NUL-delimited to survive spaces in the app name ("Agent Orchestrator-...").
find out/make -type f -print0 | while IFS= read -r -d '' f; do find out/make -type f -print0 | while IFS= read -r -d '' f; do

2
.gitignore vendored
View File

@ -57,6 +57,8 @@ Thumbs.db
# electron-forge / vite build output # electron-forge / vite build output
.vite/ .vite/
dist-electron/ dist-electron/
# electron-builder debug dump, written to the cwd on every NSIS build
builder-debug.yml
# playwright artifacts # playwright artifacts
frontend/test-results/ frontend/test-results/

View File

@ -32,8 +32,9 @@ checklist.
- `GITHUB_TOKEN` is provided automatically; the workflow already grants - `GITHUB_TOKEN` is provided automatically; the workflow already grants
`contents: write` to publish the Release. `contents: write` to publish the Release.
3. **(Optional) Windows / Linux** — the `forge.config.ts` makers already include 3. **(Optional) Windows / Linux** — the `forge.config.ts` makers already include
Squirrel (Windows), deb, and rpm. To publish them, add the matching matrix NSIS (Windows, via `makers/maker-nsis.ts`), deb, and rpm. To publish them, add
runners to `frontend-release.yml`; Windows signing needs its own certificate. the matching matrix runners to `frontend-release.yml`; Windows code-signing
needs its own certificate (still a follow-up, see issue #401).
## Cutting a release ## Cutting a release

View File

@ -1,5 +1,6 @@
import type { ForgeConfig } from "@electron-forge/shared-types"; import type { ForgeConfig } from "@electron-forge/shared-types";
import { VitePlugin } from "@electron-forge/plugin-vite"; import { VitePlugin } from "@electron-forge/plugin-vite";
import MakerNSIS from "./makers/maker-nsis";
const config: ForgeConfig = { const config: ForgeConfig = {
packagerConfig: { packagerConfig: {
@ -27,24 +28,25 @@ const config: ForgeConfig = {
}, },
rebuildConfig: {}, rebuildConfig: {},
makers: [ makers: [
// Windows installer: NSIS via electron-builder (see makers/maker-nsis.ts).
// Replaces Squirrel.Windows, which only does per-user installs with no
// custom install dir or proper uninstaller (issue #401).
new MakerNSIS(
{ {
name: "@electron-forge/maker-squirrel", appId: "dev.agent-orchestrator.desktop",
config: { productName: "Agent Orchestrator",
name: "AgentOrchestrator", icon: "assets/icon.ico",
// NuGet requires a non-empty <authors>; without it `nuget pack`
// exits 1 and the Squirrel maker fails. Mirror package.json.author.
authors: "Agent Orchestrator",
setupIcon: "assets/icon.ico",
},
}, },
["win32"],
),
{ name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} }, { name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} },
{ {
name: "@electron-forge/maker-deb", name: "@electron-forge/maker-deb",
config: { config: {
options: { options: {
// Must match packagerConfig.executableName; otherwise the deb // Must match packagerConfig.executableName, or the deb maker
// maker looks for `agent-orchestrator-frontend` (the package name) // looks for the package name and fails with "could not find
// and fails with "could not find the Electron app binary". // the Electron app binary". (Both are "agent-orchestrator".)
bin: "agent-orchestrator", bin: "agent-orchestrator",
icon: "assets/icon.png", icon: "assets/icon.png",
maintainer: "Agent Orchestrator", maintainer: "Agent Orchestrator",

View File

@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
// Capture buildForge's args without pulling in electron-builder's real machinery.
const buildForge = vi.fn<(forge: { dir: string }, options: any) => Promise<string[]>>(async () => [
"/out/make/Agent Orchestrator Setup.exe",
]);
vi.mock("app-builder-lib", () => ({ buildForge }));
import MakerNSIS from "./maker-nsis";
const makeOptions = {
dir: "/tmp/app/Agent Orchestrator-win32-x64",
makeDir: "/tmp/app/make",
appName: "Agent Orchestrator",
targetPlatform: "win32" as const,
targetArch: "x64" as const,
forgeConfig: {} as never,
packageJSON: {},
};
describe("MakerNSIS", () => {
it("targets win32 and is supported anywhere (cross-build allowed)", () => {
const maker = new MakerNSIS();
expect(maker.name).toBe("nsis");
expect(maker.defaultPlatforms).toEqual(["win32"]);
expect(maker.isSupportedOnCurrentPlatform()).toBe(true);
});
it("builds an nsis target for the requested arch and forwards config", async () => {
const maker = new MakerNSIS({ appId: "dev.agent-orchestrator.desktop", icon: "assets/icon.ico" }, ["win32"]);
// Forge resolves the (possibly arch-dependent) config before make().
await maker.prepareConfig(makeOptions.targetArch);
const artifacts = await maker.make(makeOptions);
expect(artifacts).toEqual(["/out/make/Agent Orchestrator Setup.exe"]);
const [forgeOptions, options] = buildForge.mock.calls[0];
expect(forgeOptions).toEqual({ dir: makeOptions.dir });
expect(options.win).toEqual(["nsis:x64"]);
// electron-builder must not try to publish; the workflow does that.
expect(options.config.publish).toBeNull();
expect(options.config.appId).toBe("dev.agent-orchestrator.desktop");
// productName falls back to appName when not set on the maker config.
expect(options.config.productName).toBe("Agent Orchestrator");
expect(options.config.win).toEqual({ icon: "assets/icon.ico" });
// A real installer: not Squirrel's silent one-click per-user drop.
expect(options.config.nsis.oneClick).toBe(false);
expect(options.config.nsis.allowToChangeInstallationDirectory).toBe(true);
});
});

View File

@ -0,0 +1,68 @@
import path from "node:path";
import { MakerBase, type MakerOptions } from "@electron-forge/maker-base";
import type { ForgePlatform } from "@electron-forge/shared-types";
// Electron Forge has no first-party NSIS maker, so we bridge to electron-builder's
// `buildForge`, the same engine recordly's working Windows installer uses. We drop
// Squirrel.Windows (per-user only, no custom install dir, fragile updates) for a
// real NSIS installer: per-user or per-machine, custom install directory, and a
// proper uninstaller. See https://github.com/aoagents/ReverbCode/issues/401.
//
// `buildForge` speaks Forge's legacy v5 function API, which Forge 7's class-based
// maker loader cannot resolve, so this thin MakerBase subclass adapts it.
export type MakerNSISConfig = {
// electron-builder appId; required for a well-formed NSIS installer.
appId?: string;
// Display name for the installer + Start menu shortcut. Defaults to appName.
productName?: string;
// Path to the Windows .ico used for the app and installer.
icon?: string;
// Any extra electron-builder `nsis` options, merged over our defaults.
nsis?: Record<string, unknown>;
};
export default class MakerNSIS extends MakerBase<MakerNSISConfig> {
name = "nsis";
defaultPlatforms: ForgePlatform[] = ["win32"];
isSupportedOnCurrentPlatform(): boolean {
return true;
}
async make({ dir, targetArch, appName }: MakerOptions): Promise<string[]> {
const { buildForge } = await import("app-builder-lib");
const cfg = this.config ?? {};
// Mirror buildForge's own output layout (<dir>/../make) so artifacts land
// where Forge's publisher expects them.
const output = path.join(path.dirname(path.resolve(dir)), "make");
return buildForge(
{ dir },
{
win: [`nsis:${targetArch}`],
config: {
appId: cfg.appId,
productName: cfg.productName ?? appName,
directories: { output },
// Forge owns publishing (the workflow uploads via `gh release`).
// `null` stops electron-builder from inferring a GitHub publish
// target from package.json `repository` and trying to upload,
// which fails in CI with no GH_TOKEN set.
publish: null,
...(cfg.icon ? { win: { icon: cfg.icon } } : {}),
nsis: {
// A real installer, not Squirrel's silent per-user drop.
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
...cfg.nsis,
},
},
},
);
}
}
export { MakerNSIS };

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"name": "agent-orchestrator-frontend", "name": "agent-orchestrator",
"productName": "Agent Orchestrator", "productName": "Agent Orchestrator",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
@ -28,9 +28,9 @@
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.8.0", "@electron-forge/cli": "^7.8.0",
"@electron-forge/maker-base": "^7.8.0",
"@electron-forge/maker-deb": "^7.8.0", "@electron-forge/maker-deb": "^7.8.0",
"@electron-forge/maker-rpm": "^7.8.0", "@electron-forge/maker-rpm": "^7.8.0",
"@electron-forge/maker-squirrel": "^7.8.0",
"@electron-forge/maker-zip": "^7.8.0", "@electron-forge/maker-zip": "^7.8.0",
"@electron-forge/plugin-vite": "^7.8.0", "@electron-forge/plugin-vite": "^7.8.0",
"@electron-forge/publisher-github": "^7.8.0", "@electron-forge/publisher-github": "^7.8.0",
@ -43,6 +43,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"app-builder-lib": "^26.15.3",
"electron": "^33.0.0", "electron": "^33.0.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
@ -68,7 +69,6 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"posthog-js": "^1.390.2", "posthog-js": "^1.390.2",

View File

@ -1,7 +0,0 @@
// electron-squirrel-startup ships no types. Its default export is the boolean
// result of handling any --squirrel-* startup event (true when the app should
// quit, false on macOS/Linux or a normal launch).
declare module "electron-squirrel-startup" {
const startup: boolean;
export default startup;
}

View File

@ -12,7 +12,6 @@ import {
type OpenDialogOptions, type OpenDialogOptions,
} from "electron"; } from "electron";
import { updateElectronApp } from "update-electron-app"; import { updateElectronApp } from "update-electron-app";
import squirrelStartup from "electron-squirrel-startup";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@ -38,8 +37,9 @@ import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const MAIN_WINDOW_VITE_NAME: string; declare const MAIN_WINDOW_VITE_NAME: string;
// Windows GUI/Squirrel launches have no attached console, so process.stdout and // Windows GUI launches (e.g. from a Start-menu/desktop shortcut) have no attached
// process.stderr are dead pipes. The daemon-output console.log/console.error calls // console, so process.stdout and process.stderr are dead pipes. The daemon-output
// console.log/console.error calls
// below then fail with EPIPE, and with no "error" listener that surfaces as an // below then fail with EPIPE, and with no "error" listener that surfaces as an
// uncaught exception that crashes the main process. Swallow broken-pipe write // uncaught exception that crashes the main process. Swallow broken-pipe write
// errors on the std streams: a dropped log line is harmless, the crash is not. // errors on the std streams: a dropped log line is harmless, the crash is not.
@ -49,18 +49,6 @@ const ignoreStdStreamError = (err: NodeJS.ErrnoException): void => {
process.stdout.on("error", ignoreStdStreamError); process.stdout.on("error", ignoreStdStreamError);
process.stderr.on("error", ignoreStdStreamError); process.stderr.on("error", ignoreStdStreamError);
// Windows Squirrel runs this exe with a --squirrel-{install,updated,uninstall,
// obsolete} flag during install/update/uninstall. electron-squirrel-startup
// creates/removes the shortcuts for that event and returns true; we then quit
// immediately instead of booting the full app. A full launch (window + daemon
// spawn) would hang the installer, which runs this hook and waits for it to
// exit — that hang is the Squirrel-side failure. Returns false on macOS/Linux,
// so this is a no-op there.
const isSquirrelStartup: boolean = squirrelStartup;
if (isSquirrelStartup) {
app.quit();
}
// Must run before app ready so the About panel and default-menu role labels use it. // Must run before app ready so the About panel and default-menu role labels use it.
app.setName("Agent Orchestrator"); app.setName("Agent Orchestrator");
@ -691,9 +679,6 @@ function initAutoUpdates(): void {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
// A Squirrel install/update hook already called app.quit() above; do no
// startup work (no window, no daemon spawn) so the hook exits promptly.
if (isSquirrelStartup) return;
registerRendererProtocol(); registerRendererProtocol();
createWindow(); createWindow();
void startDaemon(); void startDaemon();

View File

@ -27,6 +27,7 @@
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"forge.config.ts", "forge.config.ts",
"makers/**/*.ts",
"vite.main.config.ts", "vite.main.config.ts",
"vite.preload.config.ts", "vite.preload.config.ts",
"vite.renderer.config.ts", "vite.renderer.config.ts",