From bb97438b66451ef037d6b75cc90e8d8db3ed0318 Mon Sep 17 00:00:00 2001 From: Eric Date: Sun, 26 Apr 2026 11:35:50 -0700 Subject: [PATCH] [eric] windows install reliability: NSIS recovery layers + PS5.1 build-script ASCII fix Three changes that make Windows installs/upgrades stop stalling on the 'OpenSwarm cannot be closed' modal -- independent of the cloud OAuth work happening on the Mac side. --- electron/build/installer-recovery.nsh | 85 +++++++++++++++++++++++++++ electron/package.json | 3 +- scripts/build-app-win.ps1 | 8 +-- 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 electron/build/installer-recovery.nsh diff --git a/electron/build/installer-recovery.nsh b/electron/build/installer-recovery.nsh new file mode 100644 index 00000000..accbede9 --- /dev/null +++ b/electron/build/installer-recovery.nsh @@ -0,0 +1,85 @@ +; Bulletproof install/upgrade recovery for the "OpenSwarm cannot be closed" +; failure cascade. +; +; Three layers, each catching a different failure mode: +; +; Layer A pre-extract orphan kill (customInit) +; Kills any OpenSwarm.exe + any python.exe / node.exe rooted +; inside the install dir. Stops the dialog from EVER firing +; because of subprocess holds. +; +; Layer B bulk-delete heavy dirs with REBOOTOK (customRemoveFiles) +; Replaces NSIS's per-file delete loop on the four heavy dirs +; (python-env, router, backend, app.asar) with a single +; RMDir /r /REBOOTOK each. Faster than per-file (no Defender +; lock-retry waterfall) and locked files defer to next boot +; via MOVEFILE_DELAY_UNTIL_REBOOT instead of stalling. +; +; Layer C electron-builder's per-file silent retry stays as the safety +; net for the remaining ~5% of files (anything not in the four +; heavy dirs). No custom code needed — built into the framework. +; +; All layers run with the user's permissions (per-user install at +; %LOCALAPPDATA%). No admin elevation, no system-wide changes. +; taskkill targets only user-owned processes, wmic delete is filtered +; to install-dir-rooted processes. + +!macro customInit + ; ---- Layer A: pre-extract orphan kill ---- + ; + ; Fires before NSIS extracts anything. The two killers below cover + ; all the process-lock cases observed in the field: + ; + ; 1. App is running (user double-clicked installer without quitting) + ; → taskkill /F /IM OpenSwarm.exe /T cascades through children + ; 2. App crashed and left orphan python.exe / node.exe with no parent + ; to taskkill via PID → wmic finds them by ExecutablePath substring + ; and deletes them + ; + ; Both are safe (filter to install-dir-rooted processes only) and + ; both no-op silently if no matching processes exist. + + nsExec::Exec 'taskkill /F /IM OpenSwarm.exe /T' + Pop $0 ; discard exit code; non-fatal if no process matched + + ; wmic where-clause: match anything under the per-user install dir. + ; The single backslash in '%\\Programs\\OpenSwarm\\%' becomes a + ; literal backslash after NSIS's escape, then SQL LIKE pattern. + nsExec::Exec 'wmic process where "ExecutablePath like ''%\\Programs\\OpenSwarm\\%''" delete' + Pop $0 + + ; Brief pause so Windows kernel releases handles before NSIS tries + ; to delete the binaries in customRemoveFiles below. ~1.5s is enough + ; in practice; longer here doesn't help and slows clean installs. + Sleep 1500 +!macroend + +!macro customRemoveFiles + ; ---- Layer B: bulk-delete heavy dirs with REBOOTOK fallback ---- + ; + ; Runs in upgrade installs (no-op on first install since the dirs + ; don't exist yet). Replaces NSIS's per-file delete loop for the + ; four directories that contain ~95% of the file count, which is + ; where Defender + Search indexer contention is concentrated. + ; + ; RMDir /r recursively removes the directory tree. + ; /REBOOTOK schedules deletion via MOVEFILE_DELAY_UNTIL_REBOOT for + ; any file that's currently locked — Windows commits the deletion + ; on next boot before any process can re-acquire the handle. + ; Net effect: no file lock can block the install. Worst case is + ; one stale file lingering until reboot, which is invisible to + ; the user since the new install writes fresh files alongside. + ; + ; The standard NSIS per-file loop still runs after this for the + ; remaining ~5% of files (the few files at $INSTDIR\ root level), + ; with electron-builder's built-in retry as safety net. + + RMDir /r /REBOOTOK "$INSTDIR\resources\python-env" + RMDir /r /REBOOTOK "$INSTDIR\resources\router" + RMDir /r /REBOOTOK "$INSTDIR\resources\backend" + RMDir /r /REBOOTOK "$INSTDIR\resources\debugger" + RMDir /r /REBOOTOK "$INSTDIR\resources\frontend" + Delete /REBOOTOK "$INSTDIR\resources\app.asar" + Delete /REBOOTOK "$INSTDIR\resources\elevate.exe" + Delete /REBOOTOK "$INSTDIR\OpenSwarm.exe" +!macroend diff --git a/electron/package.json b/electron/package.json index af2eb94c..d642411b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -82,7 +82,8 @@ "createStartMenuShortcut": true, "shortcutName": "OpenSwarm", "deleteAppDataOnUninstall": false, - "artifactName": "OpenSwarm-Setup-${arch}.${ext}" + "artifactName": "OpenSwarm-Setup-${arch}.${ext}", + "include": "build/installer-recovery.nsh" }, "extraResources": [ { diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 072674c9..93f7769e 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -79,7 +79,7 @@ if (-not (Test-Path (Join-Path $UvBinDir 'uvx.exe'))) { try { Invoke-WebRequest -Uri $UvUrl -OutFile $TmpZip -UseBasicParsing Expand-Archive -Path $TmpZip -DestinationPath $TmpExtract -Force - # Only copy uvx.exe — skip uv.exe entirely. + # Only copy uvx.exe -- skip uv.exe entirely. Get-ChildItem -Path $TmpExtract -Recurse -Filter 'uvx.exe' | Select-Object -First 1 | ForEach-Object { Copy-Item $_.FullName (Join-Path $UvBinDir 'uvx.exe') -Force } Write-Host "uvx.exe downloaded and bundled." } finally { @@ -113,7 +113,7 @@ New-Item -ItemType Directory -Force -Path $McpBundleDir | Out-Null # Single-file CJS bundle. Output path: mcp-bundles\.js. Use for # packages that don't read sibling files at runtime. The import.meta.url # polyfill is applied uniformly because nearly every modern ESM package -# uses createRequire(import.meta.url) somewhere — without the polyfill, +# uses createRequire(import.meta.url) somewhere -- without the polyfill, # esbuild's ESM->CJS transform leaves import.meta.url as undefined and # the bundle crashes at module load. function Build-McpBundleSingle($PackageName, $EntrySubpath, $OutputName) { @@ -290,7 +290,7 @@ function Copy-Excluded($Source, $Dest, $Exclude) { foreach ($d in $Exclude.Dirs) { $args += '/XD'; $args += $d } foreach ($f in $Exclude.Files) { $args += '/XF'; $args += $f } & robocopy @args | Out-Null - # robocopy exit codes 0–7 are success + # robocopy exit codes 0-7 are success if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($Source -> $Dest, exit $LASTEXITCODE)" } $global:LASTEXITCODE = 0 } @@ -325,7 +325,7 @@ if (Test-Path $DevEnvPath) { Set-Content -Path $ShipEnvPath -Value $kept Write-Host "Staged OAuth credentials: $($kept.Count) keys (release secrets + personal API keys excluded)" } else { - Write-Host "WARNING: backend\.env not found — packaged build will have no OAuth credentials configured." + Write-Host "WARNING: backend\.env not found -- packaged build will have no OAuth credentials configured." Set-Content -Path $ShipEnvPath -Value '' -NoNewline } New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null