fix: continue isolated MSI migration without requiring a reboot

This commit is contained in:
sky
2026-09-22 23:06:23 +08:00
parent aef29e7cde
commit 7fa8c3da6e
3 changed files with 215 additions and 58 deletions
+155 -42
View File
@@ -109,8 +109,16 @@ Var CXOldProcess
Var CXWaitTicks
Var CXExitCode
Var CXMigrations
Var CXBootIdentity
!define CX_MIGRATION_KEY "Software\${MANUFACTURER}\${PRODUCTNAME}\Installer"
Var CXLegacyDirectory
Var CXPathInput
Var CXPathFull
Var CXPathSuffix
Var CXPathResult
Var CXPathDepth
Var CXDestinationFull
Var CXDestinationReal
Var CXDirectoryIndex
!define CX_DIRECTORY_KEY "Software\${MANUFACTURER}\${PRODUCTNAME} Installer\LegacyDirectories"
Function CXLog
Exch $R9
@@ -133,19 +141,144 @@ Function CXFail
Quit
FunctionEnd
Function CXGetBootIdentity
; Win32_OperatingSystem.LastBootUpTime is the documented Windows boot time.
; Called only for one-time MSI migration / a pending migration, never during
; ordinary NSIS updates. The bounded query cannot hang the installer.
nsExec::ExecToStack /TIMEOUT=15000 `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -Command "try { [Console]::Write((Get-CimInstance -ClassName Win32_OperatingSystem -OperationTimeoutSec 10 -ErrorAction Stop).LastBootUpTime.ToFileTimeUtc()) } catch { exit 1 }"`
Pop $0
Pop $1
${If} $0 != "0"
${OrIf} $1 == ""
Push "无法确认 Windows 重启状态。请重启后重试,或联系支持并提供安装日志。 / Cannot verify the Windows boot state. Restart and retry."
Function CXCanonicalDirectory
; Resolve the nearest existing ancestor through a real directory handle.
; Volume GUID names unify drive letters, junctions, symbolic links and 8.3
; aliases. Append only the normalized, non-existent suffix afterwards.
System::Call 'shlwapi::PathIsRelativeW(w "$CXPathInput")i.r0'
${If} $0 != 0
Push "安装目录必须是完整路径。 / An absolute installation path is required."
Call CXFail
${EndIf}
StrCpy $CXBootIdentity $1
ClearErrors
GetFullPathName $CXPathFull "$CXPathInput"
${If} ${Errors}
Push "无法读取安装目录。 / Cannot resolve the installation directory."
Call CXFail
${EndIf}
StrLen $0 $CXPathFull
StrCpy $1 $CXPathFull 1 -1
${If} $0 > 3
${AndIf} $1 == "\"
StrCpy $CXPathFull $CXPathFull -1
${EndIf}
StrCpy $CXPathSuffix ""
StrCpy $CXPathDepth 0
cx_resolve_ancestor:
System::Call 'kernel32::CreateFileW(w "$CXPathFull",i 0,i 7,p 0,i 3,i 0x02000000,p 0)p.r0 ?e'
Pop $1
${If} $0 != -1
System::Call 'kernel32::GetFinalPathNameByHandleW(p r0,w .r2,i ${NSIS_MAX_STRLEN},i 1)i.r3'
System::Call 'kernel32::CloseHandle(p r0)'
${If} $3 = 0
${OrIf} $3 >= ${NSIS_MAX_STRLEN}
Push "无法确认安装目录的实际位置。请选择本机上的其他目录。 / Cannot verify the real directory; choose another local path."
Call CXFail
${EndIf}
StrCpy $4 $2 1 -1
${If} $4 == "\"
StrCpy $2 $2 -1
${EndIf}
StrCpy $CXPathResult "$2$CXPathSuffix\"
Return
${EndIf}
${If} $1 != 2
${AndIf} $1 != 3
Push "无法访问安装目录 (Windows $1)。请选择本机上的其他目录。 / Cannot access the installation directory."
Call CXFail
${EndIf}
${GetParent} "$CXPathFull" $2
${GetFileName} "$CXPathFull" $3
${If} $2 == ""
${OrIf} $2 == $CXPathFull
${OrIf} $3 == ""
Push "无法确认安装目录的上级位置。 / Cannot resolve the installation directory's parent."
Call CXFail
${EndIf}
StrCpy $CXPathSuffix "\$3$CXPathSuffix"
StrCpy $CXPathFull $2
; GetParent of C:\child can return C:, which is drive-relative to Win32.
StrLen $0 $CXPathFull
${If} $0 = 2
StrCpy $CXPathFull "$CXPathFull\"
${EndIf}
IntOp $CXPathDepth $CXPathDepth + 1
${If} $CXPathDepth > 256
Push "安装路径层级过深。 / Installation path is too deeply nested."
Call CXFail
${EndIf}
Goto cx_resolve_ancestor
FunctionEnd
Function CXRememberLegacyDirectory
System::Call 'msi::MsiGetProductInfoExW(w "$CXLegacyProduct",p 0,i 4,w "InstallLocation",w .r0,*i ${NSIS_MAX_STRLEN})i.r1'
${If} $1 != 0
${OrIf} $0 == ""
Push "无法读取旧版安装目录。请先在系统设置中卸载旧版,再安装新版本。 / Cannot verify the legacy installation directory."
Call CXFail
${EndIf}
System::Call 'shlwapi::PathIsRelativeW(w r0)i.r1'
${If} $1 != 0
Push "旧版安装目录不是有效的完整路径,已停止迁移。 / The legacy installation path is not absolute."
Call CXFail
${EndIf}
GetFullPathName $CXLegacyDirectory "$0"
StrCpy $0 $CXLegacyDirectory 1 -1
${If} $0 != "\"
StrCpy $CXLegacyDirectory "$CXLegacyDirectory\"
${EndIf}
StrCpy $CXPathInput $CXLegacyDirectory
Call CXCanonicalDirectory
; Retain both lexical and resolved names. A later junction change must not
; redirect a delayed removal from the old lexical path into the new app.
; Kept outside the normal uninstall key so an interrupted/retried migration
; cannot forget these reserved paths. These values contain paths, no secrets.
ClearErrors
WriteRegStr HKCU "${CX_DIRECTORY_KEY}" "Path_$CXLegacyProduct" $CXLegacyDirectory
WriteRegStr HKCU "${CX_DIRECTORY_KEY}" "Real_$CXLegacyProduct" $CXPathResult
${If} ${Errors}
Push "无法保存旧版安装目录,未卸载旧版。 / Cannot save the legacy directory for safe migration."
Call CXFail
${EndIf}
FunctionEnd
Function CXValidateDestination
; No filesystem scan is needed for normal fresh installs without history.
EnumRegValue $0 HKCU "${CX_DIRECTORY_KEY}" 0
${If} $0 == ""
Return
${EndIf}
GetFullPathName $CXDestinationFull "$INSTDIR"
StrCpy $0 $CXDestinationFull 1 -1
${If} $0 != "\"
StrCpy $CXDestinationFull "$CXDestinationFull\"
${EndIf}
StrCpy $CXPathInput $CXDestinationFull
Call CXCanonicalDirectory
StrCpy $CXDestinationReal $CXPathResult
StrCpy $CXDirectoryIndex 0
cx_check_reserved:
EnumRegValue $0 HKCU "${CX_DIRECTORY_KEY}" $CXDirectoryIndex
${If} $0 == ""
Return
${EndIf}
ReadRegStr $1 HKCU "${CX_DIRECTORY_KEY}" "$0"
${If} $1 == ""
Push "旧版安装目录记录不完整,已停止安装。 / The legacy directory record is incomplete."
Call CXFail
${EndIf}
StrLen $2 $1
StrCpy $3 $CXDestinationFull $2
StrCpy $4 $CXDestinationReal $2
; NSIS StrCmp / LogicLib == are case insensitive. All names end in '\',
; so sibling folders such as Codex-X-New do not falsely match Codex-X.
${If} $3 == $1
${OrIf} $4 == $1
Push "新版本需要安装到独立目录。请使用默认用户目录,不要选择旧版目录或它的子目录。 / Choose a separate directory, not the legacy MSI directory or its children."
Call CXFail
${EndIf}
IntOp $CXDirectoryIndex $CXDirectoryIndex + 1
Goto cx_check_reserved
FunctionEnd
Function CXInitialize
@@ -192,15 +325,6 @@ Function CXInitialize
Call CXFail
${EndIf}
${EndIf}
ReadRegStr $5 HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
${If} $5 != ""
Call CXGetBootIdentity
${If} $CXBootIdentity == $5
Push "上次旧版迁移尚需 Windows 重启。请重启后再运行安装包。 / Restart Windows before continuing the previous MSI migration."
Call CXFail
${EndIf}
DeleteRegValue HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
${EndIf}
Call CXDetectLegacyMsi
FunctionEnd
@@ -226,6 +350,7 @@ Function CXDetectLegacyMsi
Call CXFail
${EndIf}
StrCpy $CXLegacyVersion $0
Call CXRememberLegacyDirectory
nsis_tauri_utils::SemverCompare "${VERSION}" $CXLegacyVersion
Pop $0
${If} $0 = -1
@@ -300,16 +425,7 @@ Function CXMigrateLegacyMsi
Push "发现异常的旧版安装记录,已停止安装。 / Too many legacy registrations; migration stopped."
Call CXFail
${EndIf}
Call CXGetBootIdentity
; Write before removal so cancellation of this process cannot lose the
; reboot barrier after MSI has scheduled file deletion. Known clean outcomes
; below clear it. Logging off alone does not bypass the boot-time check.
ClearErrors
WriteRegStr HKCU "${CX_MIGRATION_KEY}" "PendingBoot" $CXBootIdentity
${If} ${Errors}
Push "无法保存旧版迁移状态,未卸载旧版。 / Cannot save the migration recovery state."
Call CXFail
${EndIf}
Call CXValidateDestination
SetDetailsView show
Push "正在迁移旧版 $CXLegacyVersion(仅本次需要管理员授权)。 / Migrating the previous MSI installation; administrator approval is needed once."
Call CXLog
@@ -329,7 +445,6 @@ Function CXMigrateLegacyMsi
System::Free $0
${If} $1 = 0
${OrIf} $4 = 0
DeleteRegValue HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
Push "未完成管理员授权 (Windows $2),旧版保持不变。请重新运行安装包并允许卸载旧版。 / Administrator approval was not completed."
Call CXFail
${EndIf}
@@ -371,12 +486,10 @@ Function CXMigrateLegacyMsi
Call CXFail
${EndIf}
${If} $CXExitCode = 1618
DeleteRegValue HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
Push "Windows 正在安装其他软件。请等待它完成,再重新运行 Codex-X 安装包。 / Another Windows installation is running. Wait for it to finish and retry."
Call CXFail
${EndIf}
${If} $CXExitCode = 1602
DeleteRegValue HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
Push "已取消旧版卸载,未安装新版本。 / Previous-version removal was cancelled."
Call CXFail
${EndIf}
@@ -385,19 +498,17 @@ Function CXMigrateLegacyMsi
Push "旧版卸载失败 (Windows $CXExitCode),未安装新版本。请查看安装日志。 / MSI removal failed; no second installation was created."
Call CXFail
${EndIf}
${If} $CXExitCode = 3010
; Conservatively require completion after reboot before a new install.
; Never risk the old MSI's pending removals deleting the new app.
Push "旧版已移除,但 Windows 需要重启。请重启后重新运行安装包;不会自动重启。 / Restart Windows, then run this installer again."
Call CXFail
${EndIf}
; Verify the exact product really disappeared before touching new files.
System::Call 'msi::MsiGetProductInfoExW(w "$CXLegacyProduct",p 0,i 4,w "VersionString",w .r0,*i ${NSIS_MAX_STRLEN})i.r1'
${If} $1 != 1605
Push "Windows 仍保留旧版安装记录,已停止安装以避免重复。 / Previous MSI is still registered; migration stopped."
Call CXFail
${EndIf}
DeleteRegValue HKCU "${CX_MIGRATION_KEY}" "PendingBoot"
Call CXValidateDestination
${If} $CXExitCode = 3010
Push "旧版卸载完成;Windows 将稍后清理旧目录中的残留文件。新版本安装在独立目录,可继续使用,无需立即重启。 / Legacy cleanup is deferred; the new app can run from its separate directory."
Call CXLog
${EndIf}
Push "旧版 MSI 已安全移除。 / Previous MSI removed."
Call CXLog
Goto cx_migrate_next
@@ -801,6 +912,7 @@ FunctionEnd
Section EarlyChecks
Call CXWaitForOldApp
Call CXWaitForRunningApp
Call CXValidateDestination
Call CXMigrateLegacyMsi
; Abort silent installer if downgrades is disabled
!if "${ALLOWDOWNGRADES}" == "false"
@@ -913,6 +1025,7 @@ Section WebView2
SectionEnd
Section Install
Call CXValidateDestination
SetOutPath $INSTDIR
!ifmacrodef NSIS_HOOK_PREINSTALL
+3 -1
View File
@@ -17,7 +17,9 @@ Windows 新安装包使用 NSIS EXE,安装到原登录用户的本地应用目
只提升系统 `msiexec.exe /x {ProductCode}` 子进程的权限;EXE 安装器始终留在原登录用户下,避免使用另一个管理员凭据授权后安装到管理员的个人目录。旧 MSI 保留的 Program Files 路径不作为新安装目录。
卸载未成功、取消授权、其他安装事务占用、结果未知或要求重启时,停止复制新版本。已卸载但需要重启的情况使用与系统启动关联的标记,避免用户未重启即绕过提示。移除成功后再次验证旧产品注册已消失,才写入新安装。配置、账号、会话目录不属于迁移删除范围。
卸载未成功、取消授权、其他安装事务占用或结果未知时,停止复制新版本。移除返回 `0` 或 `3010` 后,再次验证旧产品注册已消失,并确认新目录与卸载前记录的旧目录隔离,才写入新安装。`3010` 表示旧文件可能需要 Windows 后续清理;在上述条件成立时继续安装,不要求用户重启电脑。
旧目录通过目录句柄解析到真实路径,处理 junction、短路径和驱动别名;目标不能是旧目录或其子目录。旧路径记录保留到异常退出后的重试,防止卸载记录消失后误选旧目录。不会用整机“待重启”状态阻止更新,也不查询启动时间。配置、账号、会话目录不属于迁移删除范围。
安装器按阶段记录日志,旧版卸载另外记录 MSI 详细日志。长时间等待会持续显示已等待时间和日志位置,不强制结束系统安装事务。
+57 -15
View File
@@ -18,6 +18,8 @@ using System.Text;
public static class CodexXMsiSmoke {
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
public static extern uint MsiEnumRelatedProducts(string code, uint reserved, uint index, StringBuilder product);
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
public static extern uint MsiGetProductInfoEx(string code, string sid, uint context, string property, StringBuilder value, ref uint length);
}
'@
function Related-Products {
@@ -81,18 +83,6 @@ try {
Assert-Installed
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
# A 3010/incomplete migration barrier must survive immediate retries. Once
# the boot identity differs, it must be cleared and allow the install.
$pendingKey = 'HKCU:\Software\yynxxxxx\Codex-X\Installer'
New-Item -Force $pendingKey | Out-Null
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToFileTimeUtc().ToString()
Set-ItemProperty $pendingKey PendingBoot $boot
Invoke-Installer $Installer '/S /UPDATE' 1
Assert-Installed
Set-ItemProperty $pendingKey PendingBoot 'previous-boot-smoke-fixture'
Invoke-Installer $Installer '/S /UPDATE'
if ((Get-ItemProperty $pendingKey -ErrorAction SilentlyContinue).PendingBoot) { throw 'Previous-boot marker was not cleared.' }
Assert-Installed
Remove-TestNsis
Write-Host 'Scenario 2: released v0.3.20 machine MSI -> current-user NSIS -> NSIS update.'
@@ -101,13 +91,65 @@ try {
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $LegacySha256) { throw 'Released MSI hash mismatch.' }
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$legacyMsi`" /qn /norestart /L*v `"$Work\legacy-install.log`""
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The genuine legacy MSI did not register.' }
# Deliberately keep the real MSI's HKCU manufacturer path to verify that the
# NSIS installer does not inherit a legacy Program Files directory.
$oldDirectory = [Text.StringBuilder]::new(1024)
[uint32]$oldDirectoryLength = 1024
$status = [CodexXMsiSmoke]::MsiGetProductInfoEx($LegacyProductCode, $null, 4, 'InstallLocation', $oldDirectory, [ref]$oldDirectoryLength)
if ($status -ne 0 -or $oldDirectory.Length -eq 0) { throw 'Could not read actual legacy MSI InstallLocation.' }
$oldPath = $oldDirectory.ToString().TrimEnd('\')
$oldExe = Join-Path $oldPath 'codex-x.exe'
$oldHash = (Get-FileHash $oldExe).Hash
# Guard the old directory, a non-existent child, and a junction alias. None
# may remove the registered MSI or overwrite its files.
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath" 1
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath\unsafe-child" 1
$alias = Join-Path $Work 'legacy-junction'
New-Item -ItemType Junction -Path $alias -Target $oldPath | Out-Null
try {
Invoke-Installer $Installer "/S /UPDATE /D=$alias\unsafe-child" 1
} finally {
[IO.Directory]::Delete($alias)
}
if ($LegacyProductCode -notin @(Related-Products) -or (Get-FileHash $oldExe).Hash -ne $oldHash) {
throw 'A refused overlapping installation changed the legacy app.'
}
# Keep the real MSI's HKCU manufacturer path to verify that NSIS does not
# inherit Program Files when the user accepts the default directory.
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Write-Host 'Windows installation smoke tests passed; configuration sentinels unchanged.'
Remove-TestNsis
Write-Host 'Scenario 3: MSI returns 3010 after removal; separate NSIS install continues without reboot.'
# A separate fixture copy of the hash-verified released MSI requests a reboot
# only after REMOVE=ALL. /norestart prevents any OS reboot. This exercises the
# genuine Windows Installer 3010 result, not a mocked installer exit code.
$rebootMsi = Join-Path $Work 'legacy-deferred-cleanup-fixture.msi'
Copy-Item $legacyMsi $rebootMsi
$msiAutomation = New-Object -ComObject WindowsInstaller.Installer
$database = $msiAutomation.OpenDatabase($rebootMsi, 1)
$query = $database.OpenView('INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (''ScheduleReboot'', ''REMOVE="ALL"'', 6500)')
$query.Execute()
$query.Close()
$summary = $database.GetType().InvokeMember('SummaryInformation', [Reflection.BindingFlags]::GetProperty, $null, $database, @(1))
$newPackageCode = '{' + [guid]::NewGuid().ToString().ToUpperInvariant() + '}'
[void]$summary.GetType().InvokeMember('Property', [Reflection.BindingFlags]::SetProperty, $null, $summary, @(9, $newPackageCode))
$summary.Persist()
$database.Commit()
foreach ($comObject in @($query, $summary, $database, $msiAutomation)) {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject)
}
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$rebootMsi`" /qn /norestart /L*v `"$Work\deferred-fixture-install.log`""
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The deferred-cleanup MSI fixture did not register.' }
$beforeMigration = Get-Date
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
$migrationLogs = @(Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' |
Where-Object { $_.LastWriteTime -ge $beforeMigration -and $_.Name -notlike '*-msi-*' })
if (-not ($migrationLogs | Select-String -SimpleMatch 'MSI exit code: 3010')) { throw 'The deferred-cleanup scenario did not actually exercise MSI exit 3010.' }
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Write-Host 'Windows installation smoke tests passed; no reboot needed, configuration sentinels unchanged.'
} finally {
Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host "--- $($_.Name) ---"