Commit 31303078 authored by AI-甘富林's avatar AI-甘富林

build: 优化安装包 payload 并补强 NSIS 安装冒烟校验

parent 0a6752f5
!include "LogicLib.nsh"
!include "nsDialogs.nsh"
!ifndef BUILD_UNINSTALLER
Var qjcInstallGuardPage
Var qjcInstallGuardLabel
Var qjcInstallGuardMessage
Var qjcPriorInstallFolder
Function qjcSetPriorInstallFolder
StrCpy $qjcPriorInstallFolder ""
ReadRegStr $0 HKCU "Software\${APP_GUID}" "InstallLocation"
${If} $0 != ""
StrCpy $qjcPriorInstallFolder "$0"
Return
${EndIf}
ReadRegStr $0 HKLM "Software\${APP_GUID}" "InstallLocation"
${If} $0 != ""
StrCpy $qjcPriorInstallFolder "$0"
${EndIf}
FunctionEnd
Function qjcBuildInstallGuardMessage
StrCpy $qjcInstallGuardMessage "无法继续安装到:$\r$\n$INSTDIR$\r$\n$\r$\n安装器在写入 Uninstall ${PRODUCT_FILENAME}.exe 前发现该目录已有残留文件,并且当前无法覆盖。通常是上一次安装残留,或者该文件仍被其它进程占用。$\r$\n$\r$\n请先关闭 ${PRODUCT_FILENAME} 和卸载程序,或改用一个空目录后重试。"
Call qjcSetPriorInstallFolder
${If} $qjcPriorInstallFolder != ""
${AndIf} $qjcPriorInstallFolder != $INSTDIR
StrCpy $qjcInstallGuardMessage "$qjcInstallGuardMessage$\r$\n$\r$\n已检测到另一处现有安装:$\r$\n$qjcPriorInstallFolder$\r$\n若要迁移安装路径,请先从旧目录卸载,再重新安装。"
${EndIf}
FunctionEnd
Function qjcEvaluateInstallGuard
StrCpy $qjcInstallGuardMessage ""
IfFileExists "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe" 0 +7
Delete "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe"
Delete "$INSTDIR\uninstallerIcon.ico"
IfFileExists "$INSTDIR\Uninstall ${PRODUCT_FILENAME}.exe" 0 +2
Call qjcBuildInstallGuardMessage
Return
IfFileExists "$INSTDIR\${PRODUCT_FILENAME}.exe" 0 +6
Delete "$INSTDIR\${PRODUCT_FILENAME}.exe"
IfFileExists "$INSTDIR\${PRODUCT_FILENAME}.exe" 0 +2
Call qjcBuildInstallGuardMessage
Return
FunctionEnd
Function qjcInstallGuardPageCreate
Call qjcEvaluateInstallGuard
${If} $qjcInstallGuardMessage == ""
Abort
${EndIf}
${If} ${Silent}
DetailPrint "$qjcInstallGuardMessage"
SetErrorLevel 123
Quit
${EndIf}
nsDialogs::Create 1018
Pop $qjcInstallGuardPage
${If} $qjcInstallGuardPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 300u 90u "$qjcInstallGuardMessage"
Pop $qjcInstallGuardLabel
nsDialogs::Show
FunctionEnd
Function qjcInstallGuardPageLeave
Call qjcEvaluateInstallGuard
${If} $qjcInstallGuardMessage != ""
MessageBox MB_OK|MB_ICONEXCLAMATION "$qjcInstallGuardMessage"
Abort
${EndIf}
FunctionEnd
!macro customPageAfterChangeDir
Page custom qjcInstallGuardPageCreate qjcInstallGuardPageLeave
!macroend
!endif
...@@ -21,3 +21,4 @@ win: ...@@ -21,3 +21,4 @@ win:
nsis: nsis:
oneClick: false oneClick: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
include: build/installer.nsh
...@@ -3,15 +3,16 @@ ...@@ -3,15 +3,16 @@
- `apps/ui` emits its production bundle into `apps/desktop/dist/renderer` - `apps/ui` emits its production bundle into `apps/desktop/dist/renderer`
- `apps/desktop` packages the final EXE - `apps/desktop` packages the final EXE
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload - `vendor/openclaw-runtime` is reserved for the pinned runtime payload
- `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; `pnpm smoke:installer` - `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; the summary now also records installed runtime payload size/file-count breakdown; `pnpm smoke:installer`
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions - `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation - `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; before the payload is finalized it now strips low-risk non-runtime artifacts such as non-template OpenClaw docs/README files, Python build helpers, `__pycache__`, `.pyc` / `.pyo`, and source maps, while preserving `openclaw/package/docs/reference/templates` required by OpenClaw workspace bootstrap, and writes payload size/file-count telemetry into `runtime-manifest.json`; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache` - `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end - `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry` - `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle` - `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat` - `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe` - `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
- `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke
- `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall QianjiangClaw.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change` - `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall QianjiangClaw.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change`
- `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall QianjiangClaw.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue` - `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall QianjiangClaw.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue`
- `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh` - `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
......
param(
[string]$SetupExe,
[string]$BaseOutputDir,
[int]$InstallTimeoutSeconds = 480,
[int]$TimeoutSeconds = 240,
[switch]$RunInstalledAppSmokeOnRelocatedInstall
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerSmokeScript = Join-Path $repoRoot 'build\scripts\installer-smoke.ps1'
$installerDir = Join-Path $repoRoot 'dist\installer'
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latestSetup) {
throw "No installer exe found under $installerDir"
}
$SetupExe = $latestSetup.FullName
}
$SetupExe = (Resolve-Path $SetupExe).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-path-change-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
if (Test-Path $BaseOutputDir) {
Remove-Item -Path $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$initialInstallDir = Join-Path $BaseOutputDir 'existing\QianjiangClaw'
$relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\QianjiangClaw'
$initialResultPath = Join-Path $BaseOutputDir 'initial-install-result.json'
$relocatedResultPath = Join-Path $BaseOutputDir 'relocated-install-result.json'
$summaryPath = Join-Path $BaseOutputDir 'installer-path-change-summary.json'
$commonArgs = @(
'-SetupExe', $SetupExe,
'-InstallTimeoutSeconds', $InstallTimeoutSeconds,
'-TimeoutSeconds', $TimeoutSeconds
)
Write-Host "Installing baseline package to $initialInstallDir"
powershell -ExecutionPolicy Bypass -File $installerSmokeScript @commonArgs `
-InstallDir $initialInstallDir `
-SmokeOutput $initialResultPath `
-SkipInstalledAppSmoke
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$initialResult = Get-Content -LiteralPath $initialResultPath | ConvertFrom-Json
if (-not $initialResult.ok) {
throw 'Initial installer materialization did not report ok=true.'
}
if (-not $initialResult.uninstallerExists) {
throw 'Initial installer materialization did not write the uninstaller.'
}
Write-Host "Installing same package to relocated path $relocatedInstallDir"
$relocatedArgs = @(
'-SetupExe', $SetupExe,
'-InstallDir', $relocatedInstallDir,
'-SmokeOutput', $relocatedResultPath,
'-InstallTimeoutSeconds', $InstallTimeoutSeconds,
'-TimeoutSeconds', $TimeoutSeconds,
'-AdditionalPriorInstallDirs', $initialInstallDir
)
if (-not $RunInstalledAppSmokeOnRelocatedInstall) {
$relocatedArgs += '-SkipInstalledAppSmoke'
}
powershell -ExecutionPolicy Bypass -File $installerSmokeScript @relocatedArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$relocatedResult = Get-Content -LiteralPath $relocatedResultPath | ConvertFrom-Json
if (-not $relocatedResult.ok) {
throw 'Relocated installer run did not report ok=true.'
}
if (-not $relocatedResult.uninstallerExists) {
throw 'Relocated installer run did not write the uninstaller.'
}
$priorPathEvidence = @($relocatedResult.installerStage.preflight.additionalPriorInstallObservations)
if ($priorPathEvidence.Count -lt 1) {
throw 'Relocated installer run did not record any prior install path observation before reinstall.'
}
if (-not $priorPathEvidence[0].uninstallerExists) {
throw 'Relocated installer run did not observe a prior install directory with an existing uninstaller before reinstall.'
}
$summary = [ordered]@{
ok = $true
setupExe = $SetupExe
baseOutputDir = $BaseOutputDir
initialInstallDir = $initialInstallDir
relocatedInstallDir = $relocatedInstallDir
initialInstallResult = $initialResultPath
relocatedInstallResult = $relocatedResultPath
priorInstallEvidenceCount = @($relocatedResult.installerStage.preflight.priorInstallEvidence).Count
priorPathEvidenceCount = $priorPathEvidence.Count
relocatedInstallerExitCode = $relocatedResult.installerExitCode
relocatedFileCount = $relocatedResult.fileCount
relocatedUninstallerExists = $relocatedResult.uninstallerExists
relocatedFailureStage = $relocatedResult.failureStage
relocatedFailureClassification = $relocatedResult.failureClassification
}
[System.IO.File]::WriteAllText($summaryPath, ($summary | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding $false))
Write-Output ($summary | ConvertTo-Json -Depth 12)
This diff is collapsed.
param(
[string]$SetupExe,
[string]$BaseOutputDir,
[int]$InstallTimeoutSeconds = 480
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerSmokeScript = Join-Path $repoRoot 'build\scripts\installer-smoke.ps1'
$installerDir = Join-Path $repoRoot 'dist\installer'
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latestSetup) {
throw "No installer exe found under $installerDir"
}
$SetupExe = $latestSetup.FullName
}
$SetupExe = (Resolve-Path $SetupExe).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-target-residue-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
if (Test-Path $BaseOutputDir) {
Remove-Item -Path $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$installDir = Join-Path $BaseOutputDir 'target\QianjiangClaw'
$resultPath = Join-Path $BaseOutputDir 'installer-target-residue-result.json'
$staleUninstallerPath = Join-Path $installDir 'Uninstall QianjiangClaw.exe'
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
[System.IO.File]::WriteAllText($staleUninstallerPath, 'stale residue', (New-Object System.Text.UTF8Encoding $false))
Write-Host "Running installer smoke against target with a preseeded stale uninstaller: $staleUninstallerPath"
powershell -ExecutionPolicy Bypass -File $installerSmokeScript `
-SetupExe $SetupExe `
-InstallDir $installDir `
-SmokeOutput $resultPath `
-InstallTimeoutSeconds $InstallTimeoutSeconds `
-SkipInstalledAppSmoke
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$result = Get-Content -LiteralPath $resultPath | ConvertFrom-Json
if (-not $result.ok) {
throw 'Installer target residue smoke did not report ok=true.'
}
if (-not $result.uninstallerExists) {
throw 'Installer target residue smoke did not materialize the uninstaller after residue cleanup.'
}
$summary = [ordered]@{
ok = $true
setupExe = $SetupExe
installDir = $installDir
staleUninstallerPath = $staleUninstallerPath
resultPath = $resultPath
installerExitCode = $result.installerExitCode
fileCount = $result.fileCount
uninstallerExists = $result.uninstallerExists
}
[System.IO.File]::WriteAllText((Join-Path $BaseOutputDir 'installer-target-residue-summary.json'), ($summary | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding $false))
Write-Output ($summary | ConvertTo-Json -Depth 12)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment