[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.
This commit is contained in:
Eric
2026-04-26 11:35:50 -07:00
parent ee91092284
commit bb97438b66
3 changed files with 91 additions and 5 deletions
+85
View File
@@ -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
+2 -1
View File
@@ -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": [
{
+4 -4
View File
@@ -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\<output>.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 07 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