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:
parent
f15021c19d
commit
e970f72d3e
|
|
@ -6,7 +6,7 @@ name: Desktop testing build
|
|||
#
|
||||
# Per OS the current electron-forge makers produce:
|
||||
# - macOS → .zip (the .dmg maker is a follow-up)
|
||||
# - Windows → Setup.exe + .nupkg + RELEASES (Squirrel)
|
||||
# - Windows → NSIS installer (.exe)
|
||||
# - Linux → .deb and .rpm
|
||||
#
|
||||
# Each OS builds on its own native runner because build-daemon.mjs compiles the
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ name: Testing build (all platforms)
|
|||
#
|
||||
# Per OS:
|
||||
# Linux -> .deb
|
||||
# Windows -> Setup.exe (+ .nupkg / RELEASES)
|
||||
# Windows -> NSIS installer (.exe)
|
||||
# macOS -> .zip (arm64; dmg + signing are follow-ups)
|
||||
#
|
||||
# Unsigned: macOS is quarantined/Gatekeeper-blocked once downloaded
|
||||
|
|
@ -29,7 +29,9 @@ jobs:
|
|||
- os: ubuntu-latest
|
||||
target: "@electron-forge/maker-deb"
|
||||
- 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
|
||||
target: "@electron-forge/maker-zip"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -58,10 +60,10 @@ jobs:
|
|||
# `npm run make` keeps the premake daemon build; --targets restricts to this
|
||||
# platform's maker.
|
||||
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
|
||||
# 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
|
||||
# AV blocking, so a clean install here does NOT prove SmartScreen/Defender
|
||||
# won't reject the unsigned binaries on end-user machines.
|
||||
|
|
@ -71,33 +73,26 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem -Path out/make/squirrel.windows -Recurse -Filter '*Setup.exe' | Select-Object -First 1
|
||||
if (-not $setup) { Write-Host '::error::no Setup.exe produced under out/make/squirrel.windows'; exit 1 }
|
||||
Write-Host "Running $($setup.FullName) --silent"
|
||||
$proc = Start-Process -FilePath $setup.FullName -ArgumentList '--silent' -PassThru -Wait
|
||||
Write-Host "Setup.exe exit code: $($proc.ExitCode)"
|
||||
$installDir = Join-Path $env:LOCALAPPDATA 'AgentOrchestrator'
|
||||
# Update.exe writes SquirrelSetup.log into the app root dir; the Setup.exe
|
||||
# bootstrapper only logs to SquirrelTemp for the earliest extraction stage.
|
||||
# Grab whichever exists and copy it into the workspace for upload.
|
||||
$log = @(
|
||||
(Join-Path $installDir 'SquirrelSetup.log'),
|
||||
(Join-Path $env:LOCALAPPDATA 'SquirrelTemp\SquirrelSetup.log')
|
||||
$setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' |
|
||||
Where-Object { $_.Name -like '*Setup*' } | Select-Object -First 1
|
||||
if (-not $setup) { $setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' | Select-Object -First 1 }
|
||||
if (-not $setup) { Write-Host '::error::no NSIS installer (.exe) produced under out/make'; exit 1 }
|
||||
Write-Host "Running $($setup.FullName) /S (silent)"
|
||||
# electron-builder NSIS (assisted installer): /S installs silently.
|
||||
$proc = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -PassThru -Wait
|
||||
Write-Host "Installer exit code: $($proc.ExitCode)"
|
||||
# Per-user assisted install lands under %LOCALAPPDATA%\Programs; a
|
||||
# per-machine install would land under Program Files.
|
||||
$installDir = @(
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs\Agent Orchestrator'),
|
||||
(Join-Path ${env:ProgramFiles} 'Agent Orchestrator')
|
||||
) | 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 (Test-Path $installDir) {
|
||||
if ($installDir) {
|
||||
Write-Host "INSTALL OK: $installDir created"
|
||||
Get-ChildItem $installDir | Select-Object Name | Format-Table -AutoSize
|
||||
} 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
|
||||
shell: bash
|
||||
env:
|
||||
|
|
@ -114,7 +109,7 @@ jobs:
|
|||
# "already exists" which is fine (|| true). Distinct asset names + --clobber
|
||||
# make uploads idempotent across re-runs.
|
||||
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
|
||||
# NUL-delimited to survive spaces in the app name ("Agent Orchestrator-...").
|
||||
find out/make -type f -print0 | while IFS= read -r -d '' f; do
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ Thumbs.db
|
|||
# electron-forge / vite build output
|
||||
.vite/
|
||||
dist-electron/
|
||||
# electron-builder debug dump, written to the cwd on every NSIS build
|
||||
builder-debug.yml
|
||||
|
||||
# playwright artifacts
|
||||
frontend/test-results/
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ checklist.
|
|||
- `GITHUB_TOKEN` is provided automatically; the workflow already grants
|
||||
`contents: write` to publish the Release.
|
||||
3. **(Optional) Windows / Linux** — the `forge.config.ts` makers already include
|
||||
Squirrel (Windows), deb, and rpm. To publish them, add the matching matrix
|
||||
runners to `frontend-release.yml`; Windows signing needs its own certificate.
|
||||
NSIS (Windows, via `makers/maker-nsis.ts`), deb, and rpm. To publish them, add
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import MakerNSIS from "./makers/maker-nsis";
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
|
|
@ -27,24 +28,25 @@ const config: ForgeConfig = {
|
|||
},
|
||||
rebuildConfig: {},
|
||||
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",
|
||||
config: {
|
||||
name: "AgentOrchestrator",
|
||||
// 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",
|
||||
},
|
||||
appId: "dev.agent-orchestrator.desktop",
|
||||
productName: "Agent Orchestrator",
|
||||
icon: "assets/icon.ico",
|
||||
},
|
||||
["win32"],
|
||||
),
|
||||
{ name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} },
|
||||
{
|
||||
name: "@electron-forge/maker-deb",
|
||||
config: {
|
||||
options: {
|
||||
// Must match packagerConfig.executableName; otherwise the deb
|
||||
// maker looks for `agent-orchestrator-frontend` (the package name)
|
||||
// and fails with "could not find the Electron app binary".
|
||||
// Must match packagerConfig.executableName, or the deb maker
|
||||
// looks for the package name and fails with "could not find
|
||||
// the Electron app binary". (Both are "agent-orchestrator".)
|
||||
bin: "agent-orchestrator",
|
||||
icon: "assets/icon.png",
|
||||
maintainer: "Agent Orchestrator",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "agent-orchestrator-frontend",
|
||||
"name": "agent-orchestrator",
|
||||
"productName": "Agent Orchestrator",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
|
|
@ -28,9 +28,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.0",
|
||||
"@electron-forge/maker-base": "^7.8.0",
|
||||
"@electron-forge/maker-deb": "^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/plugin-vite": "^7.8.0",
|
||||
"@electron-forge/publisher-github": "^7.8.0",
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"app-builder-lib": "^26.15.3",
|
||||
"electron": "^33.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
|
|
@ -68,7 +69,6 @@
|
|||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"posthog-js": "^1.390.2",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
type OpenDialogOptions,
|
||||
} from "electron";
|
||||
import { updateElectronApp } from "update-electron-app";
|
||||
import squirrelStartup from "electron-squirrel-startup";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
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_NAME: string;
|
||||
|
||||
// Windows GUI/Squirrel launches have no attached console, so process.stdout and
|
||||
// process.stderr are dead pipes. The daemon-output console.log/console.error calls
|
||||
// Windows GUI launches (e.g. from a Start-menu/desktop shortcut) have no attached
|
||||
// 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
|
||||
// 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.
|
||||
|
|
@ -49,18 +49,6 @@ const ignoreStdStreamError = (err: NodeJS.ErrnoException): void => {
|
|||
process.stdout.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.
|
||||
app.setName("Agent Orchestrator");
|
||||
|
||||
|
|
@ -691,9 +679,6 @@ function initAutoUpdates(): void {
|
|||
}
|
||||
|
||||
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();
|
||||
createWindow();
|
||||
void startDaemon();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"forge.config.ts",
|
||||
"makers/**/*.ts",
|
||||
"vite.main.config.ts",
|
||||
"vite.preload.config.ts",
|
||||
"vite.renderer.config.ts",
|
||||
|
|
|
|||
Loading…
Reference in New Issue