; ClawX Custom NSIS Installer/Uninstaller Script ; ; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI. ; Uninstall: removes the PATH entry and optionally deletes user data. !ifndef nsProcess::FindProcess !include "nsProcess.nsh" !endif !macro customHeader ; Show install details by default so users can see what stage is running. ShowInstDetails show ShowUninstDetails show !macroend !macro customCheckAppRunning ; Make stage logs visible on assisted installers (defaults to hidden). SetDetailsPrint both DetailPrint "Preparing installation..." DetailPrint "Extracting ClawX runtime files. This can take a few minutes on slower disks or while antivirus scanning is active." ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 ${if} $R0 == 0 ${if} ${isUpdated} # 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} doStopProcess: DetailPrint `Closing running "${PRODUCT_NAME}"...` # 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 ${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} # 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 # 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..." 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). ; pnpm virtual store paths can exceed the default MAX_PATH limit of 260 chars. ; Writing to HKLM requires admin privileges; on per-user installs without ; elevation this call silently fails — no crash, just no key written. 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..." InitPluginsDir ClearErrors File "/oname=$PLUGINSDIR\update-user-path.ps1" "${PROJECT_DIR}\resources\cli\win32\update-user-path.ps1" nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\update-user-path.ps1" -Action add -CliDir "$INSTDIR\resources\cli"' Pop $0 Pop $1 StrCmp $0 "error" 0 +2 DetailPrint "Warning: Failed to launch PowerShell while updating PATH." StrCmp $0 "timeout" 0 +2 DetailPrint "Warning: PowerShell PATH update timed out." StrCmp $0 "0" 0 +2 Goto _ci_done DetailPrint "Warning: PowerShell PATH update exited with code $0." _ci_done: DetailPrint "Installation steps complete." !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 File "/oname=$PLUGINSDIR\update-user-path.ps1" "${PROJECT_DIR}\resources\cli\win32\update-user-path.ps1" nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\update-user-path.ps1" -Action remove -CliDir "$INSTDIR\resources\cli"' Pop $0 Pop $1 StrCmp $0 "error" 0 +2 DetailPrint "Warning: Failed to launch PowerShell while removing PATH entry." StrCmp $0 "timeout" 0 +2 DetailPrint "Warning: PowerShell PATH removal timed out." StrCmp $0 "0" 0 +2 Goto _cu_pathDone DetailPrint "Warning: PowerShell PATH removal exited with code $0." _cu_pathDone: ; Ask user if they want to remove AppData (preserves .openclaw) MessageBox MB_YESNO|MB_ICONQUESTION \ "Do you want to remove ClawX application data?$\r$\n$\r$\nThis will delete:$\r$\n • AppData\Local\clawx (local app data)$\r$\n • AppData\Roaming\clawx (roaming app data)$\r$\n$\r$\nYour .openclaw folder (configuration & skills) will be preserved.$\r$\nSelect 'No' to keep all data for future reinstallation." \ /SD IDNO IDYES _cu_removeData IDNO _cu_skipRemove _cu_removeData: ; 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 nsExec::ExecToStack 'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"' Pop $0 Pop $1 ${endIf} ${nsProcess::Unload} ; Wait for processes to fully exit and release file handles Sleep 2000 ; --- Always remove current user's AppData first --- ; NOTE: .openclaw directory is intentionally preserved (user configuration & skills) RMDir /r "$LOCALAPPDATA\clawx" RMDir /r "$APPDATA\clawx" ; --- Retry: if directories still exist (locked files), wait and try again --- ; Check AppData\Local\clawx IfFileExists "$LOCALAPPDATA\clawx\*.*" 0 _cu_localDone Sleep 3000 RMDir /r "$LOCALAPPDATA\clawx" IfFileExists "$LOCALAPPDATA\clawx\*.*" 0 _cu_localDone nsExec::ExecToStack 'cmd.exe /c rd /s /q "$LOCALAPPDATA\clawx"' Pop $0 Pop $1 _cu_localDone: ; Check AppData\Roaming\clawx IfFileExists "$APPDATA\clawx\*.*" 0 _cu_roamingDone Sleep 3000 RMDir /r "$APPDATA\clawx" IfFileExists "$APPDATA\clawx\*.*" 0 _cu_roamingDone nsExec::ExecToStack 'cmd.exe /c rd /s /q "$APPDATA\clawx"' Pop $0 Pop $1 _cu_roamingDone: ; --- Final check: warn user if any directories could not be removed --- StrCpy $R3 "" IfFileExists "$LOCALAPPDATA\clawx\*.*" 0 +2 StrCpy $R3 "$R3$\r$\n • $LOCALAPPDATA\clawx" IfFileExists "$APPDATA\clawx\*.*" 0 +2 StrCpy $R3 "$R3$\r$\n • $APPDATA\clawx" StrCmp $R3 "" _cu_cleanupOk MessageBox MB_OK|MB_ICONEXCLAMATION \ "Some data directories could not be removed (files may be in use):$\r$\n$R3$\r$\n$\r$\nPlease delete them manually after restarting your computer." _cu_cleanupOk: ; --- For per-machine (all users) installs, enumerate all user profiles --- StrCpy $R0 0 _cu_enumLoop: EnumRegKey $R1 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" $R0 StrCmp $R1 "" _cu_enumDone ReadRegStr $R2 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$R1" "ProfileImagePath" StrCmp $R2 "" _cu_enumNext ; ExpandEnvStrings requires distinct src and dest registers ExpandEnvStrings $R3 $R2 StrCmp $R3 $PROFILE _cu_enumNext ; NOTE: .openclaw directory is intentionally preserved for all users RMDir /r "$R3\AppData\Local\clawx" RMDir /r "$R3\AppData\Roaming\clawx" _cu_enumNext: IntOp $R0 $R0 + 1 Goto _cu_enumLoop _cu_enumDone: _cu_skipRemove: !macroend