diff --git a/package.json b/package.json index 568d6c5..443c3f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.4.6", + "version": "0.4.7-alpha.3", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", @@ -62,7 +62,7 @@ "package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs", "package:mac": "pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", "package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", - "package:win": "pnpm run prep:win-binaries && pnpm run package && node scripts/patch-nsis-extract.mjs && node scripts/run-electron-builder.mjs --win --publish never", + "package:win": "pnpm run prep:win-binaries && pnpm run package && node scripts/patch-nsis-win.mjs && node scripts/run-electron-builder.mjs --win --publish never", "package:linux": "pnpm run package && node scripts/run-electron-builder.mjs --linux --publish never", "release": "pnpm run uv:download && pnpm run package && node scripts/run-electron-builder.mjs --publish always", "preversion": "node scripts/pre-version-fetch-tags.mjs", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index c6567bc..0b4c171 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -23,6 +23,8 @@ const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSy const { join, dirname, basename, relative } = require('path'); const { ELECTRON_MAIN_RUNTIME_PACKAGES } = require('./openclaw-bundle-config.mjs'); const { patchNsisExtractTemplate } = require('./patch-nsis-extract.mjs'); +const { patchNsisInstallSectionTemplate } = require('./patch-nsis-install-section.mjs'); +const { patchNsisUninstallTemplate } = require('./patch-nsis-uninstall.mjs'); // On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH // limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled @@ -951,10 +953,13 @@ exports.default = async function afterPack(context) { console.log(`[after-pack] 🩹 Patched ${asarLruCount} lru-cache instance(s) in app.asar.unpacked`); } } - // 6. [Windows only] Ensure NSIS uses direct 7z extraction (also patched pre-build in package:win). + // 6. [Windows only] Ensure NSIS templates are patched (also done pre-build in package:win). if (platform === 'win32') { - if (patchNsisExtractTemplate()) { - console.log('[after-pack] ⚡ NSIS extract template ready (direct 7z to $INSTDIR).'); + const extractOk = patchNsisExtractTemplate(); + const installSectionOk = patchNsisInstallSectionTemplate(); + const uninstallOk = patchNsisUninstallTemplate(); + if (extractOk && installSectionOk && uninstallOk) { + console.log('[after-pack] ⚡ NSIS install templates ready (overwrite upgrade).'); } } }; diff --git a/scripts/installer.nsh b/scripts/installer.nsh index 6ea169e..5eea502 100644 --- a/scripts/installer.nsh +++ b/scripts/installer.nsh @@ -3,16 +3,78 @@ ; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI. ; Uninstall: removes the PATH entry and optionally deletes user data. +!include "LogicLib.nsh" + !ifndef nsProcess::FindProcess !include "nsProcess.nsh" !endif +Var /GLOBAL clawxRollbackDir + !macro customHeader ; Show install details by default so users can see what stage is running. ShowInstDetails show ShowUninstDetails show !macroend +!ifndef BUILD_UNINSTALLER +Function ClawXMoveLegacyInstallDir + Exch $R6 + + ${if} $R6 == "" + Goto _clawx_legacy_move_done + ${endIf} + ${if} $R6 == $INSTDIR + Goto _clawx_legacy_move_done + ${endIf} + + IfFileExists "$R6\" 0 _clawx_legacy_move_done + DetailPrint "Moving previous ClawX installation at $R6 out of the way..." + SetOutPath $TEMP + nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$R6', [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` + Pop $0 + Pop $1 + Sleep 1000 + StrCpy $R8 0 + + _clawx_legacy_find_free_stale: + IfFileExists "$R6._stale_$R8\" 0 _clawx_legacy_found_free_stale + IntOp $R8 $R8 + 1 + Goto _clawx_legacy_find_free_stale + + _clawx_legacy_found_free_stale: + ClearErrors + Rename "$R6" "$R6._stale_$R8" + IfErrors 0 _clawx_legacy_stale_moved + DetailPrint "Waiting for file locks at $R6 to clear..." + nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$R6', [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` + Pop $0 + Pop $1 + Sleep 2000 + ClearErrors + Rename "$R6" "$R6._stale_$R8" + IfErrors 0 _clawx_legacy_stale_moved + DetailPrint "Removing previous ClawX installation at $R6..." + nsExec::ExecToStack 'cmd.exe /c rd /s /q "$R6"' + Pop $0 + Pop $1 + Goto _clawx_legacy_move_done + _clawx_legacy_stale_moved: + ExecShell "" "cmd.exe" `/c ping -n 61 127.0.0.1 >nul & rd /s /q "$R6._stale_$R8"` SW_HIDE + + _clawx_legacy_move_done: + ClearErrors + Pop $R6 +FunctionEnd + +!macro clawxMoveLegacyInstallDir ROOT_KEY + ReadRegStr $R6 ${ROOT_KEY} "${INSTALL_REGISTRY_KEY}" InstallLocation + Push $R6 + Call ClawXMoveLegacyInstallDir +!macroend +!endif + + !macro customCheckAppRunning ; Make stage logs visible on assisted installers (defaults to hidden). SetDetailsPrint both @@ -103,12 +165,55 @@ ; 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 + ; Do not continue while the old UI process is still alive. Continuing in that + ; state can leave the running old process/window in place, making the user see + ; the old version after an otherwise successful extract. Use process-list + ; commands instead of nsProcess here: field diagnostics showed ClawX.exe can + ; remain alive while the old installer still reports success; this check must + ; fail closed even when taskkill or the nsProcess plugin misses/elevates poorly. + StrCpy $R7 0 + _clawx_verify_closed: + nsExec::ExecToStack `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "if (Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.Name -ieq '${APP_EXECUTABLE_FILENAME}' }) { exit 0 } else { exit 1 }"` + Pop $R0 + Pop $R1 + ${if} $R0 != 0 + nsExec::ExecToStack 'cmd.exe /c tasklist /FI "IMAGENAME eq ${APP_EXECUTABLE_FILENAME}" | find /I "${APP_EXECUTABLE_FILENAME}" >nul' + Pop $R0 + Pop $R1 + ${endIf} + ${if} $R0 == 0 + IntOp $R7 $R7 + 1 + DetailPrint `Waiting for "${PRODUCT_NAME}" to close (attempt $R7)...` + nsExec::ExecToStack 'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"' + Pop $0 + Pop $1 + nsExec::ExecToStack `cmd.exe /c wmic process where "name='${APP_EXECUTABLE_FILENAME}'" call terminate` + Pop $0 + Pop $1 + Sleep 2000 + ${if} $R7 < 5 + Goto _clawx_verify_closed + ${endIf} + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "ClawX is still running and cannot be replaced safely. Please close ClawX and retry installation." /SD IDCANCEL IDRETRY _clawx_verify_closed + SetErrorLevel 2 + Quit + ${endIf} - ; Pre-emptively clear the old installation directory so that the 7z + !ifndef BUILD_UNINSTALLER + StrCpy $clawxRollbackDir "" + + ; 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 + + ; Move legacy installs discovered in both registry hives before handling the + ; current $INSTDIR. This covers per-user <-> per-machine migrations and + ; custom install directories where the new $INSTDIR is not the old location. + !insertmacro clawxMoveLegacyInstallDir HKCU + !insertmacro clawxMoveLegacyInstallDir HKLM + + ; 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. @@ -152,9 +257,17 @@ Pop $0 Pop $1 Sleep 2000 + RMDir "$INSTDIR" + IfFileExists "$INSTDIR\" 0 _recreate_clean_instdir + DetailPrint "Failed to remove previous installation directory; aborting to avoid leaving the old version installed." + MessageBox MB_OK|MB_ICONEXCLAMATION "Unable to replace the previous ClawX installation because files are still locked. Please close ClawX and retry installation." /SD IDOK + SetErrorLevel 2 + Quit + _recreate_clean_instdir: CreateDirectory "$INSTDIR" Goto _instdir_clean _stale_moved: + StrCpy $clawxRollbackDir "$INSTDIR._stale_$R8" CreateDirectory "$INSTDIR" _instdir_clean: @@ -171,26 +284,9 @@ Pop $1 _openclaw_skills_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 + ; Opposite-hive registry cleanup is intentionally done in customInstall after + ; successful extraction, so a failed update can still roll back to the old app + ; with its existing uninstall entries intact. !endif !macroend @@ -227,6 +323,25 @@ !macroend !macro customInstall + ; Now that the new files and current-hive registry entries have been written, + ; remove stale entries from the opposite hive so Windows Apps & Features does + ; not continue showing the old version after cross-hive upgrades. + DetailPrint "Clearing stale ClawX registry entries from the opposite install scope..." + ${if} $installMode == "all" + DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}" + DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" + !ifdef UNINSTALL_REGISTRY_KEY_2 + DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY_2}" + !endif + ${else} + DeleteRegKey HKLM "${UNINSTALL_REGISTRY_KEY}" + DeleteRegKey HKLM "${INSTALL_REGISTRY_KEY}" + !ifdef UNINSTALL_REGISTRY_KEY_2 + DeleteRegKey HKLM "${UNINSTALL_REGISTRY_KEY_2}" + !endif + ${endIf} + ClearErrors + ; 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.). diff --git a/scripts/patch-nsis-extract.mjs b/scripts/patch-nsis-extract.mjs index cb7cc7a..c74dce6 100644 --- a/scripts/patch-nsis-extract.mjs +++ b/scripts/patch-nsis-extract.mjs @@ -27,9 +27,15 @@ export const EXTRACT_APP_PACKAGE_NSH = join( 'extractAppPackage.nsh', ); -const PATCHED_MACRO = [ +const PATCH_MARKER = 'ClawX-patched-v2: extract directly to $INSTDIR and fail closed'; +const LEGACY_PATCH_MARKER = 'ClawX-patched: extract directly to $INSTDIR'; +const LEGACY_CONTINUE_ON_EXTRACT_FAILURE = 'continuing overwrite install anyway'; +const FATAL_EXTRACT_FAILURE_DETAIL = 'Failed to extract ClawX files after multiple attempts.'; +const ROLLBACK_EXTRACT_FAILURE_DETAIL = 'Restoring previous ClawX installation after failed update'; + +const PATCHED_EXTRACT_MACRO = [ '!macro extractUsing7za FILE', - ' ; ClawX-patched: extract directly to $INSTDIR (skip temp + CopyFiles).', + ` ; ${PATCH_MARKER}.`, ' StrCpy $R9 0', ' clawx_extract_attempt:', ' IntOp $R9 $R9 + 1', @@ -38,7 +44,7 @@ const PATCHED_MACRO = [ ' ClearErrors', ' Nsis7z::Extract "${FILE}"', ' IfErrors 0 clawx_extract_done', - ' ${if} $R9 < 3', + ' ${if} $R9 < 5', ' DetailPrint "Releasing file locks before retry..."', ' nsExec::ExecToStack \'taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"\'', ' Pop $0', @@ -49,13 +55,79 @@ const PATCHED_MACRO = [ ' Sleep 3000', ' Goto clawx_extract_attempt', ' ${endIf}', - ' MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDRETRY IDRETRY clawx_extract_attempt IDCANCEL clawx_extract_abort', - ' clawx_extract_abort:', + ' DetailPrint "Failed to extract ClawX files after multiple attempts."', + ' ${if} $clawxRollbackDir != ""', + ' IfFileExists "$clawxRollbackDir\\" 0 clawx_extract_show_error', + ' DetailPrint "Restoring previous ClawX installation after failed update..."', + ' SetOutPath $TEMP', + ' RMDir /r "$INSTDIR"', + ' Rename "$clawxRollbackDir" "$INSTDIR"', + ' ${endIf}', + ' clawx_extract_show_error:', + ' MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK', + ' SetErrorLevel 2', ' Quit', ' clawx_extract_done:', '!macroend', ].join('\n'); +const DECOMPRESS_MACRO = [ + '!macro decompress', + ' !ifdef ZIP_COMPRESSION', + ' nsisunz::Unzip "$PLUGINSDIR\\app-$packageArch.zip" "$INSTDIR"', + ' Pop $R0', + ' StrCmp $R0 "success" +3', + ' MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)$\\n$R0"', + ' Quit', + ' !else', + ' !insertmacro extractUsing7za "$PLUGINSDIR\\app-$packageArch.7z"', + ' !endif', + '!macroend', + '', +].join('\n'); + +const EXTRACT_MACRO_PATTERN = /!macro extractUsing7za FILE[\s\S]*?!macroend/; + +function countExtractMacros(content) { + return (content.match(/!macro extractUsing7za FILE/g) || []).length; +} + +function isTemplateHealthy(content) { + return content.includes(PATCH_MARKER) + && countExtractMacros(content) === 1 + && content.includes(FATAL_EXTRACT_FAILURE_DETAIL) + && content.includes(ROLLBACK_EXTRACT_FAILURE_DETAIL) + && content.includes('$(decompressionFailed)') + && content.includes('SetErrorLevel 2') + && content.includes('Quit') + && !content.includes('$(appCannotBeClosed)') + && !content.includes(LEGACY_CONTINUE_ON_EXTRACT_FAILURE); +} + +function hasStaleExtractPatch(content) { + return content.includes(PATCH_MARKER) + || content.includes(LEGACY_PATCH_MARKER) + || content.includes(LEGACY_CONTINUE_ON_EXTRACT_FAILURE) + || content.includes('$(appCannotBeClosed)'); +} + +/** + * @param {string} content + * @returns {string} + */ +export function restoreExtractAppPackageTemplate(content) { + const extractIdx = content.indexOf('!macro extractUsing7za FILE'); + const decompressIdx = content.indexOf('!macro decompress'); + const cutIdx = Math.min( + extractIdx === -1 ? Number.POSITIVE_INFINITY : extractIdx, + decompressIdx === -1 ? Number.POSITIVE_INFINITY : decompressIdx, + ); + if (!Number.isFinite(cutIdx)) { + return content; + } + return `${content.slice(0, cutIdx)}${PATCHED_EXTRACT_MACRO}\n\n${DECOMPRESS_MACRO}`; +} + /** * @param {string} [targetPath] * @returns {boolean} true when template is patched (or already patched) @@ -66,20 +138,32 @@ export function patchNsisExtractTemplate(targetPath = EXTRACT_APP_PACKAGE_NSH) { return false; } - const original = readFileSync(targetPath, 'utf8'); - if (original.includes('ClawX-patched')) { + let original = readFileSync(targetPath, 'utf8'); + + if (isTemplateHealthy(original)) { return true; } - if (!original.includes('CopyFiles')) { + if (countExtractMacros(original) !== 1) { + console.warn('[patch-nsis-extract] Corrupted template detected; restoring tail section.'); + original = restoreExtractAppPackageTemplate(original); + writeFileSync(targetPath, original, 'utf8'); + if (isTemplateHealthy(original)) { + console.log('[patch-nsis-extract] Restored extractAppPackage.nsh (overwrite upgrade extract).'); + return true; + } + } + + if (hasStaleExtractPatch(original)) { + console.warn('[patch-nsis-extract] Stale ClawX extract patch detected; replacing with fail-closed patch.'); + } else if (!original.includes('CopyFiles')) { console.warn('[patch-nsis-extract] CopyFiles not found — NSIS template may have changed.'); return false; } - // Use a replacer function so NSIS `${if}` tokens are not treated as replace groups. const patched = original.replace( - /(!macro extractUsing7za FILE[\s\S]*?!macroend)/, - () => PATCHED_MACRO, + EXTRACT_MACRO_PATTERN, + () => PATCHED_EXTRACT_MACRO, ); if (patched === original) { diff --git a/scripts/patch-nsis-install-section.mjs b/scripts/patch-nsis-install-section.mjs new file mode 100644 index 0000000..6866ed5 --- /dev/null +++ b/scripts/patch-nsis-install-section.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Patch electron-builder's assisted install section so the app-running guard is + * executed in the elevated UAC inner instance as well. + * + * electron-builder skips CHECK_APP_RUNNING for assisted installers when + * UAC_IsInnerInstance is true. Per-machine upgrades run the actual file + * replacement in that inner process, so the old ClawX.exe can remain alive, + * keep $INSTDIR locked, and make the installer appear successful while the old + * files remain installed. + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +export const INSTALL_SECTION_NSH = join( + ROOT, + 'node_modules', + 'app-builder-lib', + 'templates', + 'nsis', + 'installSection.nsh', +); + +const PATCH_MARKER = 'ClawX-patched-v2: run app-running guard in assisted UAC inner instance'; + +const ORIGINAL_ASSISTED_CHECK = [ + '!else', + ' ${ifNot} ${UAC_IsInnerInstance}', + ' !insertmacro CHECK_APP_RUNNING', + ' ${endif}', + '!endif', +].join('\n'); + +const PATCHED_ASSISTED_CHECK = [ + '!else', + ` ; ${PATCH_MARKER}.`, + ' ; Per-machine assisted upgrades perform the actual install in the elevated', + ' ; UAC inner instance; skipping CHECK_APP_RUNNING there can leave ClawX.exe', + ' ; alive and make the installer false-succeed with old files still present.', + ' !insertmacro CHECK_APP_RUNNING', + '!endif', +].join('\n'); + +function isTemplateHealthy(content) { + return content.includes(PATCH_MARKER) + && content.includes('!insertmacro CHECK_APP_RUNNING') + && !content.includes([' ${ifNot} ${UAC_IsInnerInstance}', ' !insertmacro CHECK_APP_RUNNING'].join('\n')); +} + +/** + * @param {string} [targetPath] + * @returns {boolean} + */ +export function patchNsisInstallSectionTemplate(targetPath = INSTALL_SECTION_NSH) { + if (!existsSync(targetPath)) { + console.warn('[patch-nsis-install-section] installSection.nsh not found, skipping.'); + return false; + } + + const original = readFileSync(targetPath, 'utf8'); + if (isTemplateHealthy(original)) { + return true; + } + + if (!original.includes(ORIGINAL_ASSISTED_CHECK)) { + console.warn('[patch-nsis-install-section] Assisted CHECK_APP_RUNNING block not found — template may have changed.'); + return false; + } + + const patched = original.replace(ORIGINAL_ASSISTED_CHECK, PATCHED_ASSISTED_CHECK); + if (patched === original) { + console.warn('[patch-nsis-install-section] No changes applied.'); + return false; + } + + writeFileSync(targetPath, patched, 'utf8'); + console.log('[patch-nsis-install-section] Patched installSection.nsh (run app guard in assisted UAC inner instance).'); + return true; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const ok = patchNsisInstallSectionTemplate(); + process.exit(ok ? 0 : 1); +} diff --git a/scripts/patch-nsis-uninstall.mjs b/scripts/patch-nsis-uninstall.mjs new file mode 100644 index 0000000..c9c796c --- /dev/null +++ b/scripts/patch-nsis-uninstall.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Patch electron-builder's uninstallOldVersion to skip the legacy uninstaller on + * upgrades. customCheckAppRunning already kills processes and moves $INSTDIR + * aside; running the old uninstaller often fails on locked openclaw bundles and + * shows a misleading "app cannot be closed" dialog even when ClawX is not running. + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +export const INSTALL_UTIL_NSH = join( + ROOT, + 'node_modules', + 'app-builder-lib', + 'templates', + 'nsis', + 'include', + 'installUtil.nsh', +); + +const PATCH_MARKER = 'ClawX-patched: skip legacy uninstaller'; + +const SKIP_LEGACY_UNINSTALLER = [ + ' ; ClawX-patched: skip legacy uninstaller on upgrades.', + ' ; customCheckAppRunning already killed processes and moved $INSTDIR aside.', + ' DetailPrint "Skipping legacy uninstaller; continuing with overwrite install..."', + ' ClearErrors', + ' Return', +].join('\n'); + +const LEGACY_UNINSTALL_BLOCK = + / StrCpy \$uninstallerFileNameTemp "\$PLUGINSDIR\\old-uninstaller\.exe"[\s\S]*? DoesNotExist:\r?\n SetErrors\r?\nFunctionEnd/; + +/** + * @param {string} [targetPath] + * @returns {boolean} + */ +export function patchNsisUninstallTemplate(targetPath = INSTALL_UTIL_NSH) { + if (!existsSync(targetPath)) { + console.warn('[patch-nsis-uninstall] installUtil.nsh not found, skipping.'); + return false; + } + + const original = readFileSync(targetPath, 'utf8'); + if (original.includes(PATCH_MARKER)) { + return true; + } + + if (!original.includes('Function uninstallOldVersion')) { + console.warn('[patch-nsis-uninstall] uninstallOldVersion not found — template may have changed.'); + return false; + } + + if (!LEGACY_UNINSTALL_BLOCK.test(original)) { + console.warn('[patch-nsis-uninstall] Legacy uninstall block regex did not match.'); + return false; + } + + const patched = original.replace( + LEGACY_UNINSTALL_BLOCK, + `${SKIP_LEGACY_UNINSTALLER}\nFunctionEnd`, + ); + + if (patched === original) { + console.warn('[patch-nsis-uninstall] No changes applied.'); + return false; + } + + writeFileSync(targetPath, patched, 'utf8'); + console.log('[patch-nsis-uninstall] Patched installUtil.nsh (skip legacy uninstaller on upgrade).'); + return true; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const ok = patchNsisUninstallTemplate(); + process.exit(ok ? 0 : 1); +} diff --git a/scripts/patch-nsis-win.mjs b/scripts/patch-nsis-win.mjs new file mode 100644 index 0000000..aa7e541 --- /dev/null +++ b/scripts/patch-nsis-win.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * Apply all ClawX NSIS template patches before makensis (package:win). + */ + +import { fileURLToPath } from 'node:url'; +import { patchNsisExtractTemplate } from './patch-nsis-extract.mjs'; +import { patchNsisInstallSectionTemplate } from './patch-nsis-install-section.mjs'; +import { patchNsisUninstallTemplate } from './patch-nsis-uninstall.mjs'; + +const extractOk = patchNsisExtractTemplate(); +const installSectionOk = patchNsisInstallSectionTemplate(); +const uninstallOk = patchNsisUninstallTemplate(); + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(extractOk && installSectionOk && uninstallOk ? 0 : 1); +} diff --git a/tests/fixtures/installUtil-unpatched.snippet.nsh b/tests/fixtures/installUtil-unpatched.snippet.nsh new file mode 100644 index 0000000..968efa9 --- /dev/null +++ b/tests/fixtures/installUtil-unpatched.snippet.nsh @@ -0,0 +1,34 @@ +Function uninstallOldVersion + StrCpy $uninstallerFileNameTemp "$PLUGINSDIR\old-uninstaller.exe" + !insertmacro copyFile "$uninstallerFileName" "$uninstallerFileNameTemp" + + # Retry counter + StrCpy $R5 0 + + UninstallLoop: + IntOp $R5 $R5 + 1 + + ${if} $R5 > 5 + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt + Return + ${endIf} + + OneMoreAttempt: + ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0 + ifErrors TryInPlace CheckResult + + TryInPlace: + ExecWait '"$uninstallerFileName" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0 + ifErrors DoesNotExist + + CheckResult: + ${if} $R0 == 0 + Return + ${endIf} + + Sleep 1000 + Goto UninstallLoop + + DoesNotExist: + SetErrors +FunctionEnd diff --git a/tests/unit/installer-nsh.test.ts b/tests/unit/installer-nsh.test.ts new file mode 100644 index 0000000..eee682e --- /dev/null +++ b/tests/unit/installer-nsh.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; + +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const installerNsh = readFileSync(join(process.cwd(), 'scripts/installer.nsh'), 'utf8'); + +describe('installer.nsh running-app guard', () => { + it('fails closed when ClawX.exe remains alive during overwrite install', () => { + const guardStart = installerNsh.indexOf('Do not continue while the old UI process is still alive'); + const guardEnd = installerNsh.indexOf('!ifndef BUILD_UNINSTALLER', guardStart); + const guard = installerNsh.slice(guardStart, guardEnd); + + expect(guardStart).toBeGreaterThan(-1); + expect(guardEnd).toBeGreaterThan(guardStart); + expect(guard).toContain('Get-CimInstance -ClassName Win32_Process'); + expect(guard).toContain('tasklist /FI "IMAGENAME eq ${APP_EXECUTABLE_FILENAME}"'); + expect(guard).toContain('taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"'); + expect(guard).toContain('wmic process where "name=\'${APP_EXECUTABLE_FILENAME}\'" call terminate'); + expect(guard).toContain('SetErrorLevel 2'); + expect(guard).toContain('Quit'); + expect(guard).toContain('ClawX is still running and cannot be replaced safely'); + expect(guard).not.toContain('${nsProcess::FindProcess}'); + }); +}); diff --git a/tests/unit/patch-nsis-extract.test.ts b/tests/unit/patch-nsis-extract.test.ts index 2e058dc..aec93f8 100644 --- a/tests/unit/patch-nsis-extract.test.ts +++ b/tests/unit/patch-nsis-extract.test.ts @@ -2,14 +2,41 @@ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { patchNsisExtractTemplate } from '../../scripts/patch-nsis-extract.mjs'; +import { + patchNsisExtractTemplate, + restoreExtractAppPackageTemplate, +} from '../../scripts/patch-nsis-extract.mjs'; +import { patchNsisUninstallTemplate } from '../../scripts/patch-nsis-uninstall.mjs'; -const SAMPLE_MACRO = `!macro extractUsing7za FILE +const FIXTURES = join(fileURLToPath(new URL('.', import.meta.url)), '../fixtures'); + +const SAMPLE_EXTRACT_MACRO = `!macro extractUsing7za FILE Push $OUTDIR - CopyFiles /SILENT "$PLUGINSDIR\\7z-out\\*" $OUTDIR + CopyFiles /SILENT "$PLUGINSDIR\\\\7z-out\\\\*" $OUTDIR !macroend`; +const SAMPLE_FILE = `!macro ia32_app_files + File /oname=$PLUGINSDIR\\\\app-32.7z "\\\${APP_32}" +!macroend + +${SAMPLE_EXTRACT_MACRO} + +!macro decompress + !ifdef ZIP_COMPRESSION + Quit + !else + !insertmacro extractUsing7za "$PLUGINSDIR\\\\app-$packageArch.7z" + !endif +!macroend +`; + +const SAMPLE_UNINSTALL_FUNCTION = readFileSync( + join(FIXTURES, 'installUtil-unpatched.snippet.nsh'), + 'utf8', +); + describe('patch-nsis-extract', () => { let tempDir = ''; @@ -23,13 +50,90 @@ describe('patch-nsis-extract', () => { it('replaces CopyFiles-based extractUsing7za with direct 7z extraction', () => { tempDir = mkdtempSync(join(tmpdir(), 'clawx-patch-nsis-')); const target = join(tempDir, 'extractAppPackage.nsh'); - writeFileSync(target, `before\n${SAMPLE_MACRO}\nafter`, 'utf8'); + writeFileSync(target, SAMPLE_FILE, 'utf8'); expect(patchNsisExtractTemplate(target)).toBe(true); const result = readFileSync(target, 'utf8'); - expect(result).toContain('ClawX-patched'); + expect(result).toContain('ClawX-patched-v2'); expect(result).not.toContain('CopyFiles /SILENT'); + expect(result).not.toContain('$(appCannotBeClosed)'); + expect(result).toContain('$(decompressionFailed)'); + expect(result).toContain('Quit'); + expect(result).toContain('SetErrorLevel 2'); + expect(result).toContain('Restoring previous ClawX installation after failed update'); + expect(result).not.toContain('continuing overwrite install anyway'); expect(patchNsisExtractTemplate(target)).toBe(true); }); + + it('upgrades stale ClawX extract patches that used to continue after extract failure', () => { + tempDir = mkdtempSync(join(tmpdir(), 'clawx-patch-nsis-')); + const target = join(tempDir, 'extractAppPackage.nsh'); + writeFileSync( + target, + SAMPLE_FILE.replace( + SAMPLE_EXTRACT_MACRO, + `!macro extractUsing7za FILE + ; ClawX-patched: extract directly to $INSTDIR. + ClearErrors + Nsis7z::Extract "\${FILE}" + DetailPrint "Extract reported file locks; continuing overwrite install anyway..." +!macroend`, + ), + 'utf8', + ); + + expect(patchNsisExtractTemplate(target)).toBe(true); + + const result = readFileSync(target, 'utf8'); + expect(result).toContain('ClawX-patched-v2'); + expect(result).toContain('Failed to extract ClawX files after multiple attempts.'); + expect(result).toContain('$(decompressionFailed)'); + expect(result).toContain('Quit'); + expect(result).toContain('SetErrorLevel 2'); + expect(result).toContain('Restoring previous ClawX installation after failed update'); + expect(result).not.toContain('continuing overwrite install anyway'); + }); + + it('restores and re-patches a corrupted template', () => { + tempDir = mkdtempSync(join(tmpdir(), 'clawx-patch-nsis-')); + const target = join(tempDir, 'extractAppPackage.nsh'); + writeFileSync( + target, + `${SAMPLE_FILE}\nMessageBox MB_RETRYCANCEL "$(appCannotBeClosed)"\n!macro extractUsing7za FILE\n broken\n!macroend\n`, + 'utf8', + ); + + const restored = restoreExtractAppPackageTemplate(readFileSync(target, 'utf8')); + expect(restored).not.toContain('broken'); + expect(restored).not.toContain('$(appCannotBeClosed)'); + + writeFileSync(target, restored, 'utf8'); + expect(patchNsisExtractTemplate(target)).toBe(true); + expect(readFileSync(target, 'utf8').match(/!macro extractUsing7za FILE/g)).toHaveLength(1); + }); +}); + +describe('patch-nsis-uninstall', () => { + let tempDir = ''; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + it('skips the legacy uninstaller retry loop on upgrades', () => { + tempDir = mkdtempSync(join(tmpdir(), 'clawx-patch-nsis-')); + const target = join(tempDir, 'installUtil.nsh'); + writeFileSync(target, `before\n${SAMPLE_UNINSTALL_FUNCTION}\nafter`, 'utf8'); + + expect(patchNsisUninstallTemplate(target)).toBe(true); + + const result = readFileSync(target, 'utf8'); + expect(result).toContain('Skipping legacy uninstaller'); + expect(result).not.toContain('MessageBox MB_RETRYCANCEL'); + expect(patchNsisUninstallTemplate(target)).toBe(true); + }); }); diff --git a/tests/unit/patch-nsis-install-section.test.ts b/tests/unit/patch-nsis-install-section.test.ts new file mode 100644 index 0000000..f63c008 --- /dev/null +++ b/tests/unit/patch-nsis-install-section.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { patchNsisInstallSectionTemplate } from '../../scripts/patch-nsis-install-section.mjs'; + +const SAMPLE_INSTALL_SECTION = `!ifdef ONE_CLICK + !insertmacro CHECK_APP_RUNNING +!else + \${ifNot} \${UAC_IsInnerInstance} + !insertmacro CHECK_APP_RUNNING + \${endif} +!endif + +!insertmacro installApplicationFiles +`; + +describe('patchNsisInstallSectionTemplate', () => { + let tempDir: string | undefined; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + }); + + it('runs CHECK_APP_RUNNING for assisted UAC inner installs', () => { + tempDir = mkdtempSync(join(tmpdir(), 'clawx-patch-nsis-install-section-')); + const target = join(tempDir, 'installSection.nsh'); + writeFileSync(target, SAMPLE_INSTALL_SECTION, 'utf8'); + + expect(patchNsisInstallSectionTemplate(target)).toBe(true); + + const result = readFileSync(target, 'utf8'); + expect(result).toContain('ClawX-patched-v2: run app-running guard in assisted UAC inner instance'); + expect(result).toContain('!insertmacro CHECK_APP_RUNNING'); + expect(result).not.toContain('${ifNot} ${UAC_IsInnerInstance}\n !insertmacro CHECK_APP_RUNNING'); + expect(patchNsisInstallSectionTemplate(target)).toBe(true); + }); +});