fix(installer): skip legacy NSIS uninstall on Windows overwrite upgrades (#1072)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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).');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
89
scripts/patch-nsis-install-section.mjs
Normal file
89
scripts/patch-nsis-install-section.mjs
Normal 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);
|
||||
}
|
||||
82
scripts/patch-nsis-uninstall.mjs
Normal file
82
scripts/patch-nsis-uninstall.mjs
Normal 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);
|
||||
}
|
||||
17
scripts/patch-nsis-win.mjs
Normal file
17
scripts/patch-nsis-win.mjs
Normal 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);
|
||||
}
|
||||
34
tests/fixtures/installUtil-unpatched.snippet.nsh
vendored
Normal file
34
tests/fixtures/installUtil-unpatched.snippet.nsh
vendored
Normal 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
|
||||
26
tests/unit/installer-nsh.test.ts
Normal file
26
tests/unit/installer-nsh.test.ts
Normal 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}');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
43
tests/unit/patch-nsis-install-section.test.ts
Normal file
43
tests/unit/patch-nsis-install-section.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user