feat: add LICENSE file and custom NSIS installer script for NIANXX

This commit is contained in:
DEV_DSW
2026-04-24 09:00:36 +08:00
parent df600272d6
commit e721ed221d
3 changed files with 390 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

View File

@@ -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

367
scripts/installer.nsh Normal file
View File

@@ -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