fix(installer): resolve upgrade hang caused by locked files in installation directory (#711)

This commit is contained in:
paisley
2026-03-30 14:29:48 +08:00
committed by GitHub
Unverified
parent 870abb99c4
commit 91a86a4a76
3 changed files with 258 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.3.2",
"version": "0.3.3-beta.0",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",

View File

@@ -695,4 +695,52 @@ exports.default = async function afterPack(context) {
console.log(`[after-pack] 🩹 Patched ${asarLruCount} lru-cache instance(s) in app.asar.unpacked`);
}
}
// 6. [Windows only] Patch NSIS extractAppPackage.nsh to skip CopyFiles
//
// electron-builder's extractUsing7za macro decompresses app-64.7z into a temp
// directory, then uses CopyFiles to copy ~300MB (thousands of small files) to
// $INSTDIR. With Windows Defender real-time scanning each file, CopyFiles
// alone takes 3-5 minutes and makes the installer appear frozen.
//
// Patch: replace the macro with a direct Nsis7z::Extract to $INSTDIR. This is
// safe because customCheckAppRunning in installer.nsh already renames the old
// $INSTDIR to a _stale_ directory, so the target is always an empty dir.
// The Nsis7z plugin streams LZMA2 data directly to disk — no temp copy needed.
if (platform === 'win32') {
const extractNsh = join(
__dirname, '..', 'node_modules', 'app-builder-lib',
'templates', 'nsis', 'include', 'extractAppPackage.nsh'
);
if (existsSync(extractNsh)) {
const { readFileSync: readFS, writeFileSync: writeFS } = require('fs');
const original = readFS(extractNsh, 'utf8');
// Only patch once (idempotent check)
if (original.includes('CopyFiles') && !original.includes('ClawX-patched')) {
// Replace the extractUsing7za macro body with a direct extraction.
// Keep the macro signature so the rest of the template compiles unchanged.
const patched = original.replace(
/(!macro extractUsing7za FILE[\s\S]*?!macroend)/,
[
'!macro extractUsing7za FILE',
' ; ClawX-patched: extract directly to $INSTDIR (skip temp + CopyFiles).',
' ; customCheckAppRunning already renamed old $INSTDIR to _stale_X,',
' ; so the target directory is always empty. Nsis7z streams LZMA2 data',
' ; directly to disk — ~10s vs 3-5 min for CopyFiles with Windows Defender.',
' Nsis7z::Extract "${FILE}"',
'!macroend',
].join('\n')
);
if (patched !== original) {
writeFS(extractNsh, patched, 'utf8');
console.log('[after-pack] ⚡ Patched extractAppPackage.nsh: CopyFiles eliminated, using direct Nsis7z::Extract.');
} else {
console.warn('[after-pack] ⚠️ extractAppPackage.nsh regex did not match — template may have changed.');
}
} else if (original.includes('ClawX-patched')) {
console.log('[after-pack] ⚡ extractAppPackage.nsh already patched (idempotent skip).');
}
}
}
};

View File

@@ -23,59 +23,204 @@
${if} $R0 == 0
${if} ${isUpdated}
# allow app to exit without explicit kill
Sleep 1000
Goto doStopProcess
# Auto-update: the app is already shutting down (quitAndInstall was called).
# The before-quit handler needs up to 8s to gracefully stop the Gateway
# process tree (5s timeout + force-terminate + re-quit). Wait for the
# app to exit on its own before resorting to force-kill.
DetailPrint `Waiting for "${PRODUCT_NAME}" to finish shutting down...`
Sleep 8000
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 != 0
# App exited cleanly. Still kill long-lived child processes (gateway,
# uv, python) which may not have followed the app's graceful exit.
nsExec::ExecToStack 'taskkill /F /IM openclaw-gateway.exe'
Pop $0
Pop $1
Goto done_killing
${endIf}
# App didn't exit in time; fall through to force-kill
${endIf}
${if} ${isUpdated} ; skip the dialog for auto-updates
${else}
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess
Quit
${endIf}
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess
Quit
doStopProcess:
DetailPrint `Closing running "${PRODUCT_NAME}"...`
# Silently kill the process using nsProcess instead of taskkill / cmd.exe
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
# to ensure that files are not "in-use"
Sleep 300
# Kill ALL processes whose executable lives inside $INSTDIR.
# This covers ClawX.exe (multiple Electron processes), openclaw-gateway.exe,
# python.exe (skills runtime), uv.exe (package manager), and any other
# child process that might hold file locks in the installation directory.
#
# Use PowerShell Get-CimInstance for path-based matching (most reliable),
# with taskkill name-based fallback for restricted environments.
# Note: Using backticks ` ` for the NSIS string allows us to use single quotes inside.
nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$INSTDIR', [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
Pop $0
Pop $1
# Retry counter
StrCpy $R1 0
${if} $0 != 0
# PowerShell failed (policy restriction, etc.) — fall back to name-based kill
nsExec::ExecToStack 'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"'
Pop $0
Pop $1
${endIf}
loop:
IntOp $R1 $R1 + 1
# Also kill well-known child processes that may have detached from the
# Electron process tree or run from outside $INSTDIR (e.g. system python).
nsExec::ExecToStack 'taskkill /F /IM openclaw-gateway.exe'
Pop $0
Pop $1
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0
# wait to give a chance to exit gracefully
Sleep 1000
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${If} $R0 == 0
DetailPrint `Waiting for "${PRODUCT_NAME}" to close.`
Sleep 2000
${else}
Goto not_running
${endIf}
${else}
Goto not_running
${endIf}
# Wait for Windows to fully release file handles after process termination.
# 5 seconds accommodates slow antivirus scanners and filesystem flush delays.
Sleep 5000
DetailPrint "Processes terminated. Continuing installation..."
# App likely running with elevated permissions.
# Ask user to close it manually
${if} $R1 > 1
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop
Quit
${else}
Goto loop
${endIf}
not_running:
done_killing:
${nsProcess::Unload}
${endIf}
; Even if ClawX.exe was not detected as running, orphan child processes
; (python.exe, openclaw-gateway.exe, uv.exe, etc.) from a previous crash
; or unclean shutdown may still hold file locks inside $INSTDIR.
; Unconditionally kill any process whose executable lives in the install dir.
nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$INSTDIR', [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
Pop $0
Pop $1
; Always kill known process names as a belt-and-suspenders approach.
; PowerShell path-based kill may miss processes if the old ClawX was installed
; in a different directory than $INSTDIR (e.g., per-machine -> per-user migration).
; taskkill is name-based and catches processes regardless of their install location.
nsExec::ExecToStack 'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"'
Pop $0
Pop $1
nsExec::ExecToStack 'taskkill /F /IM openclaw-gateway.exe'
Pop $0
Pop $1
; Note: we intentionally do NOT kill uv.exe globally — it is a popular
; Python package manager and other users/CI jobs may have uv running.
; The PowerShell path-based kill above already handles uv inside $INSTDIR.
; Brief wait for handle release (main wait was already done above if app was running)
Sleep 2000
; Release NSIS's CWD on $INSTDIR BEFORE the rename check.
; NSIS sets CWD to $INSTDIR in .onInit; Windows refuses to rename a directory
; that any process (including NSIS itself) has as its CWD.
SetOutPath $TEMP
; Pre-emptively clear the old installation directory so that the 7z
; extraction `CopyFiles` step in extractAppPackage.nsh won't fail on
; locked files. electron-builder's extractUsing7za macro extracts to a
; temp folder first, then uses `CopyFiles /SILENT` to copy into $INSTDIR.
; If ANY file in $INSTDIR is still locked, CopyFiles fails and triggers a
; "Can't modify ClawX's files" retry loop -> "ClawX 无法关闭" dialog.
;
; Strategy: rename (move) the old $INSTDIR out of the way. Rename works
; even when AV/indexer have files open for reading (they use
; FILE_SHARE_DELETE sharing mode), whereas CopyFiles fails because it
; needs write/overwrite access which some AV products deny.
; Check if a previous installation exists ($INSTDIR is a directory).
; Use trailing backslash — the correct NSIS idiom for directory existence.
; (IfFileExists "$INSTDIR\*.*" only matches files containing a dot and
; would fail for extensionless files or pure-subdirectory layouts.)
IfFileExists "$INSTDIR\" 0 _instdir_clean
; Find the first available stale directory name (e.g. $INSTDIR._stale_0)
; This ensures we NEVER have to synchronously delete old leftovers before
; renaming the current $INSTDIR. We just move it out of the way instantly.
StrCpy $R8 0
_find_free_stale:
IfFileExists "$INSTDIR._stale_$R8\" 0 _found_free_stale
IntOp $R8 $R8 + 1
Goto _find_free_stale
_found_free_stale:
ClearErrors
Rename "$INSTDIR" "$INSTDIR._stale_$R8"
IfErrors 0 _stale_moved
; Rename still failed — a process reopened a file or holds CWD in $INSTDIR.
; We must delete forcibly and synchronously to make room for CopyFiles.
; This can be slow (~1-3 minutes) if there are 10,000+ files and AV is active.
nsExec::ExecToStack 'cmd.exe /c rd /s /q "$INSTDIR"'
Pop $0
Pop $1
Sleep 2000
CreateDirectory "$INSTDIR"
Goto _instdir_clean
_stale_moved:
CreateDirectory "$INSTDIR"
_instdir_clean:
; Pre-emptively remove the old uninstall registry entry so that
; electron-builder's uninstallOldVersion skips the old uninstaller entirely.
;
; Why: uninstallOldVersion has a hardcoded 5-retry loop that runs the old
; uninstaller repeatedly. The old uninstaller's atomicRMDir fails on locked
; files (antivirus, indexing) causing a blocking "ClawX 无法关闭" dialog.
; Deleting UninstallString makes uninstallOldVersion return immediately.
; The new installer will overwrite / extract all files on top of the old dir.
; registryAddInstallInfo will write the correct new entries afterwards.
; Clean both SHELL_CONTEXT and HKCU to cover cross-hive upgrades
; (e.g. old install was per-user, new install is per-machine or vice versa).
DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString
DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString
DeleteRegValue HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString
DeleteRegValue HKCU "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString
!ifdef UNINSTALL_REGISTRY_KEY_2
DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY_2}" UninstallString
DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY_2}" QuietUninstallString
DeleteRegValue HKCU "${UNINSTALL_REGISTRY_KEY_2}" UninstallString
DeleteRegValue HKCU "${UNINSTALL_REGISTRY_KEY_2}" QuietUninstallString
!endif
!macroend
; Override electron-builder's handleUninstallResult to prevent the
; "ClawX 无法关闭" retry dialog when the old uninstaller fails.
;
; During upgrades, electron-builder copies the old uninstaller to a temp dir
; and runs it silently. The old uninstaller uses atomicRMDir to rename every
; file out of $INSTDIR. If ANY file is still locked (antivirus scanner,
; Windows Search indexer, delayed kernel handle release after taskkill), it
; aborts with a non-zero exit code. The default handler retries 5× then shows
; a blocking MessageBox.
;
; This macro clears the error and lets the new installer proceed — it will
; simply overwrite / extract new files on top of the (partially cleaned) old
; installation directory. This is safe because:
; 1. Processes have already been force-killed in customCheckAppRunning.
; 2. The new installer extracts a complete, self-contained file tree.
; 3. Any leftover old files that weren't removed are harmless.
!macro customUnInstallCheck
${if} $R0 != 0
DetailPrint "Old uninstaller exited with code $R0. Continuing with overwrite install..."
${endIf}
ClearErrors
!macroend
; Same safety net for the HKEY_CURRENT_USER uninstall path.
; Without this, handleUninstallResult would show a fatal error and Quit.
!macro customUnInstallCheckCurrentUser
${if} $R0 != 0
DetailPrint "Old uninstaller (current user) exited with code $R0. Continuing..."
${endIf}
ClearErrors
!macroend
!macro customInstall
; Async cleanup of old dirs left by the rename loop in customCheckAppRunning.
; Wait 60s before starting deletion to avoid I/O contention with ClawX's
; first launch (Windows Defender scan, ASAR mapping, etc.).
; ExecShell SW_HIDE is completely detached from NSIS and avoids pipe blocking.
IfFileExists "$INSTDIR._stale_0\" 0 _ci_stale_cleaned
; Use PowerShell to extract the basename of $INSTDIR so the glob works
; even when the user picked a custom install folder name.
; E.g. $INSTDIR = D:\Apps\MyClaw → glob = MyClaw._stale_*
ExecShell "" "cmd.exe" `/c ping -n 61 127.0.0.1 >nul & cd /d "$INSTDIR\.." & for /d %D in ("$INSTDIR._stale_*") do rd /s /q "%D"` SW_HIDE
_ci_stale_cleaned:
DetailPrint "Core files extracted. Finalizing system integration..."
; Enable Windows long path support (Windows 10 1607+ / Windows 11).
@@ -85,6 +230,15 @@
DetailPrint "Enabling long-path support (if permissions allow)..."
WriteRegDWORD HKLM "SYSTEM\CurrentControlSet\Control\FileSystem" "LongPathsEnabled" 1
; Add $INSTDIR to Windows Defender exclusion list so that real-time scanning
; doesn't block the first app launch (Defender scans every newly-created file,
; causing 10-30s startup delay on a fresh install). Requires elevation;
; silently fails on non-admin per-user installs (no harm done).
DetailPrint "Configuring Windows Defender exclusion..."
nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Add-MpPreference -ExclusionPath '$INSTDIR' -ErrorAction SilentlyContinue"`
Pop $0
Pop $1
; Use PowerShell to update the current user's PATH.
; This avoids NSIS string-buffer limits and preserves long PATH values.
DetailPrint "Updating user PATH for the OpenClaw CLI..."
@@ -107,6 +261,11 @@
!macroend
!macro customUnInstall
; Remove Windows Defender exclusion added during install
nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Remove-MpPreference -ExclusionPath '$INSTDIR' -ErrorAction SilentlyContinue"`
Pop $0
Pop $1
; Remove resources\cli from user PATH via PowerShell so long PATH values are handled safely
InitPluginsDir
ClearErrors
@@ -130,11 +289,13 @@
/SD IDNO IDYES _cu_removeData IDNO _cu_skipRemove
_cu_removeData:
; Kill any lingering ClawX processes to release file locks on electron-store
; JSON files (settings.json, clawx-providers.json, window-state.json, etc.)
; Kill any lingering ClawX processes (and their child process trees) to
; release file locks on electron-store JSON files, Gateway sockets, etc.
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
nsExec::ExecToStack 'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"'
Pop $0
Pop $1
${endIf}
${nsProcess::Unload}
@@ -200,12 +361,13 @@
ReadRegStr $R2 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$R1" "ProfileImagePath"
StrCmp $R2 "" _cu_enumNext
ExpandEnvStrings $R2 $R2
StrCmp $R2 $PROFILE _cu_enumNext
; ExpandEnvStrings requires distinct src and dest registers
ExpandEnvStrings $R3 $R2
StrCmp $R3 $PROFILE _cu_enumNext
RMDir /r "$R2\.openclaw"
RMDir /r "$R2\AppData\Local\clawx"
RMDir /r "$R2\AppData\Roaming\clawx"
RMDir /r "$R3\.openclaw"
RMDir /r "$R3\AppData\Local\clawx"
RMDir /r "$R3\AppData\Roaming\clawx"
_cu_enumNext:
IntOp $R0 $R0 + 1