From e721ed221d3b74f112de4c32f084f1f043bff8f6 Mon Sep 17 00:00:00 2001 From: DEV_DSW <562304744@qq.com> Date: Fri, 24 Apr 2026 09:00:36 +0800 Subject: [PATCH] feat: add LICENSE file and custom NSIS installer script for NIANXX --- LICENSE | 21 +++ electron-builder.yml | 2 + scripts/installer.nsh | 367 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 LICENSE create mode 100644 scripts/installer.nsh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7230e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ValueCell Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/electron-builder.yml b/electron-builder.yml index 44124e4..24695fb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -102,6 +102,8 @@ nsis: createStartMenuShortcut: true shortcutName: NIANXX uninstallDisplayName: NIANXX + license: LICENSE + include: scripts/installer.nsh installerIcon: resources/icons/icon.ico uninstallerIcon: resources/icons/icon.ico diff --git a/scripts/installer.nsh b/scripts/installer.nsh new file mode 100644 index 0000000..9bbcde8 --- /dev/null +++ b/scripts/installer.nsh @@ -0,0 +1,367 @@ +; NIANXX 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 NIANXX 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} + ${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 NIANXX.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 NIANXX.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 NIANXX 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 NIANXX's files" retry loop -> "NIANXX cannot be closed" 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 "NIANXX cannot be closed" 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 +; "NIANXX cannot be closed" 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 5x 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 NIANXX'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\MyNianXX -> glob = MyNianXX._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 NIANXX application data?$\r$\n$\r$\nThis will delete:$\r$\n - AppData\Local\nianxx (local app data)$\r$\n - AppData\Roaming\nianxx (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 NIANXX 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\nianxx" + RMDir /r "$APPDATA\nianxx" + + ; --- Retry: if directories still exist (locked files), wait and try again --- + + ; Check AppData\Local\nianxx + IfFileExists "$LOCALAPPDATA\nianxx\*.*" 0 _cu_localDone + Sleep 3000 + RMDir /r "$LOCALAPPDATA\nianxx" + IfFileExists "$LOCALAPPDATA\nianxx\*.*" 0 _cu_localDone + nsExec::ExecToStack 'cmd.exe /c rd /s /q "$LOCALAPPDATA\nianxx"' + Pop $0 + Pop $1 + _cu_localDone: + + ; Check AppData\Roaming\nianxx + IfFileExists "$APPDATA\nianxx\*.*" 0 _cu_roamingDone + Sleep 3000 + RMDir /r "$APPDATA\nianxx" + IfFileExists "$APPDATA\nianxx\*.*" 0 _cu_roamingDone + nsExec::ExecToStack 'cmd.exe /c rd /s /q "$APPDATA\nianxx"' + Pop $0 + Pop $1 + _cu_roamingDone: + + ; --- Final check: warn user if any directories could not be removed --- + StrCpy $R3 "" + IfFileExists "$LOCALAPPDATA\nianxx\*.*" 0 +2 + StrCpy $R3 "$R3$\r$\n - $LOCALAPPDATA\nianxx" + IfFileExists "$APPDATA\nianxx\*.*" 0 +2 + StrCpy $R3 "$R3$\r$\n - $APPDATA\nianxx" + 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\nianxx" + RMDir /r "$R3\AppData\Roaming\nianxx" + + _cu_enumNext: + IntOp $R0 $R0 + 1 + Goto _cu_enumLoop + + _cu_enumDone: + _cu_skipRemove: +!macroend