fix(installer): skip legacy NSIS uninstall on Windows overwrite upgrades (#1072)

This commit is contained in:
paisley
2026-05-27 18:41:12 +08:00
committed by GitHub
parent 800932507b
commit 1830bbe787
11 changed files with 645 additions and 46 deletions

View File

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

View File

@@ -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).');
}
}
};

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

@@ -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}');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});