diff --git a/.github/workflows/windows-installer-check.yml b/.github/workflows/windows-installer-check.yml index 183dc9e..8431a4b 100644 --- a/.github/workflows/windows-installer-check.yml +++ b/.github/workflows/windows-installer-check.yml @@ -14,6 +14,21 @@ concurrency: cancel-in-progress: false jobs: + msi-probe: + name: Diagnose legacy MSI discovery + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Install NSIS compiler for the isolated probe + shell: pwsh + run: | + if (-not (Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) { + choco install nsis --yes --no-progress + } + - name: Compare MSI discovery in 64-bit, 32-bit and installer processes + shell: pwsh + run: ./scripts/windows-msi-probe.ps1 windows-installer: name: Verify Windows update migration runs-on: windows-latest @@ -36,15 +51,16 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with: workspaces: apps/desktop/src-tauri + cache-on-failure: true - run: pnpm install --frozen-lockfile - run: pnpm --dir apps/desktop typecheck - run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1 - - name: Build installer without publishing or release signing credentials + - name: Build test installer without publishing or release signing credentials run: | Set-Content -Path "$env:RUNNER_TEMP\installer-check.json" -Value '{"bundle":{"createUpdaterArtifacts":false}}' -Encoding utf8 - pnpm --dir apps/desktop tauri build --bundles nsis --config "$env:RUNNER_TEMP\installer-check.json" --ci + pnpm --dir apps/desktop tauri build --debug --bundles nsis --config "$env:RUNNER_TEMP\installer-check.json" --ci - name: Test real MSI migration and subsequent updates run: | - $installers = @(Get-ChildItem apps/desktop/src-tauri/target/release/bundle/nsis -Filter '*.exe') + $installers = @(Get-ChildItem apps/desktop/src-tauri/target/debug/bundle/nsis -Filter '*.exe') if ($installers.Count -ne 1) { throw 'Expected one Windows installer.' } ./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName diff --git a/apps/desktop/src-tauri/nsis/installer.nsi b/apps/desktop/src-tauri/nsis/installer.nsi index ee98577..facc446 100644 --- a/apps/desktop/src-tauri/nsis/installer.nsi +++ b/apps/desktop/src-tauri/nsis/installer.nsi @@ -118,6 +118,7 @@ Var CXPathDepth Var CXDestinationFull Var CXDestinationReal Var CXDirectoryIndex +Var CXReservedPath !define CX_DIRECTORY_KEY "Software\${MANUFACTURER}\${PRODUCTNAME} Installer\LegacyDirectories" Function CXLog @@ -125,7 +126,8 @@ Function CXLog Push $R8 DetailPrint "$R9" FileOpen $R8 "$CXLogPath" a - FileWrite $R8 "$R9$\r$\n" + FileSeek $R8 0 END + FileWriteUTF16LE $R8 "$R9$\r$\n" FileClose $R8 Pop $R8 Pop $R9 @@ -267,6 +269,21 @@ Function CXValidateDestination Push "旧版安装目录记录不完整,已停止安装。 / The legacy directory record is incomplete." Call CXFail ${EndIf} + StrCpy $CXReservedPath $1 + StrCpy $2 $0 5 + ${If} $2 == "Path_" + ; Re-resolve the old lexical name as well: a junction may have changed + ; since cancellation. Keep the originally saved Real_ guard too. + StrCpy $CXPathInput $CXReservedPath + Call CXCanonicalDirectory + StrLen $2 $CXPathResult + StrCpy $3 $CXDestinationReal $2 + ${If} $3 == $CXPathResult + Push "新版本不能安装到旧目录指向的位置。请选择独立目录。 / The chosen path resolves inside a legacy installation directory." + Call CXFail + ${EndIf} + ${EndIf} + StrCpy $1 $CXReservedPath StrLen $2 $1 StrCpy $3 $CXDestinationFull $2 StrCpy $4 $CXDestinationReal $2 @@ -291,7 +308,7 @@ Function CXInitialize IfErrors 0 +3 MessageBox MB_ICONSTOP "无法创建安装日志。请检查当前用户目录的写入权限。 / Cannot create installer log." Quit - FileWrite $2 'Codex-X ${VERSION}: current-user installer$\r$\n' + FileWriteUTF16LE /BOM $2 'Codex-X ${VERSION}: current-user installer, NSIS x86 Unicode, target ${ARCH}$\r$\n' FileClose $2 ; The actual wait is in EarlyChecks on the installation worker thread. Keep ; the handle now so a recycled PID cannot make us wait on an unrelated app. @@ -334,6 +351,8 @@ Function CXDetectLegacyMsi StrCpy $CXLegacyProduct "" StrCpy $CXLegacyVersion "" System::Call 'msi::MsiEnumRelatedProductsW(w "${CODEXX_LEGACY_UPGRADE_CODE}",i 0,i 0,w .r0)i.r1' + Push "MSI enum: result=$1, product=$0, upgrade=${CODEXX_LEGACY_UPGRADE_CODE}" + Call CXLog ${If} $1 = 259 Return ${EndIf} @@ -345,6 +364,8 @@ Function CXDetectLegacyMsi ; Only the shipped, machine-wide MSI is supported for automatic migration. ; Query its machine context explicitly, including with alternate UAC creds. System::Call 'msi::MsiGetProductInfoExW(w "$CXLegacyProduct",p 0,i 4,w "VersionString",w .r0,*i ${NSIS_MAX_STRLEN})i.r1' + Push "MSI machine-context version: result=$1, version=$0, product=$CXLegacyProduct" + Call CXLog ${If} $1 != 0 Push "旧版安装信息不完整 (Windows $1)。请先在系统设置中卸载旧版 Codex-X,再运行此安装包。 / Previous installation could not be verified." Call CXFail diff --git a/scripts/windows-install-smoke.ps1 b/scripts/windows-install-smoke.ps1 index 890b75f..d4ece01 100644 --- a/scripts/windows-install-smoke.ps1 +++ b/scripts/windows-install-smoke.ps1 @@ -89,7 +89,7 @@ try { $legacyMsi = Join-Path $Work 'Codex-X-0.3.20.msi' Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi 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`"" + Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$Work\legacy-install.log`"" if ($LegacyProductCode -notin @(Related-Products)) { throw 'The genuine legacy MSI did not register.' } $oldDirectory = [Text.StringBuilder]::new(1024) [uint32]$oldDirectoryLength = 1024 @@ -139,7 +139,7 @@ try { 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`"" + Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$rebootMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /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' diff --git a/scripts/windows-msi-probe.ps1 b/scripts/windows-msi-probe.ps1 new file mode 100644 index 0000000..8643961 --- /dev/null +++ b/scripts/windows-msi-probe.ps1 @@ -0,0 +1,82 @@ +# Diagnostic only; installs/removes the hash-pinned fixture on an ephemeral runner. +$ErrorActionPreference = 'Stop' +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') { + throw 'This MSI diagnostic is restricted to an ephemeral Windows Actions runner.' +} +$work = Join-Path $env:RUNNER_TEMP 'codex-x-msi-probe' +New-Item -ItemType Directory -Force $work | Out-Null +$legacyMsi = Join-Path $work 'legacy.msi' +Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi +if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152') { + throw 'MSI fixture hash mismatch.' +} +$compiler = (Get-Command makensis.exe -ErrorAction SilentlyContinue).Source +if (-not $compiler) { $compiler = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' } +if (-not (Test-Path $compiler)) { throw 'makensis.exe is required for the native MSI probe.' } +$psProbe = @' +$ErrorActionPreference = 'Stop' +Add-Type @" +using System.Runtime.InteropServices; +using System.Text; +public static class MsiProbeNative { + [DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] + public static extern uint MsiEnumRelatedProductsW(string upgrade, uint reserved, uint index, StringBuilder product); + [DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] + public static extern uint MsiGetProductInfoExW(string product, string sid, uint context, string property, StringBuilder value, ref uint length); +} +"@ +$product = [Text.StringBuilder]::new(39) +$rc = [MsiProbeNative]::MsiEnumRelatedProductsW('{1482A8E8-9217-517B-8528-93A6C90E0C2F}', 0, 0, $product) +Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) EnumRelated rc=$rc product=$product" +foreach ($property in @('VersionString', 'InstallLocation')) { + $value = [Text.StringBuilder]::new(1024) + [uint32]$length = 1024 + $rc = [MsiProbeNative]::MsiGetProductInfoExW('{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}', $null, 4, $property, $value, [ref]$length) + Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) machine $property rc=$rc value=$value" +} +'@ +$psProbePath = Join-Path $work 'probe-api.ps1' +Set-Content $psProbePath $psProbe -Encoding utf8 +$nsis = @' +Unicode true +RequestExecutionLevel user +OutFile "probe.exe" +Name "Codex-X MSI API probe" +SilentInstall silent +Function .onInit + FileOpen $9 "$EXEDIR\nsis-probe.log" w + FileWriteUTF16LE /BOM $9 "NSIS x86 Unicode native MSI API probe$\r$\n" + System::Call 'msi::MsiEnumRelatedProductsW(w "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,w .r0)i.r1' + FileWriteUTF16LE $9 "EnumRelatedW result=$1 product=$0$\r$\n" + System::Call 'msi::MsiEnumRelatedProductsA(m "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,m .r0)i.r1' + FileWriteUTF16LE $9 "EnumRelatedA result=$1 product=$0$\r$\n" + System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "VersionString",w .r0,*i 1024)i.r1' + FileWriteUTF16LE $9 "Machine VersionString result=$1 value=$0$\r$\n" + System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "InstallLocation",w .r0,*i 1024)i.r1' + FileWriteUTF16LE $9 "Machine InstallLocation result=$1 value=$0$\r$\n" + FileClose $9 + Quit +FunctionEnd +Section +SectionEnd +'@ +$nsisPath = Join-Path $work 'probe.nsi' +Set-Content $nsisPath $nsis -Encoding utf8 +function Run-Wait([string]$File, [string]$Arguments) { + $process = Start-Process $File -ArgumentList $Arguments -PassThru + if (-not $process.WaitForExit(300000)) { throw 'Probe subprocess timed out; no MSI process was terminated.' } + $process.Refresh() + Write-Host "Process completed: $File, exit=$($process.ExitCode)" + if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { throw "Subprocess failure: $($process.ExitCode)" } +} +try { + Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$work\install.log`"" + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath + & "$env:SystemRoot\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath + & $compiler /V3 $nsisPath + if ($LASTEXITCODE -ne 0) { throw 'Native NSIS probe compilation failed.' } + Run-Wait (Join-Path $work 'probe.exe') '/S' + Get-Content (Join-Path $work 'nsis-probe.log') +} finally { + Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/x {F71E3F3F-A463-4397-AB46-206D3FAC3FBD} /qn /norestart /L*v `"$work\uninstall.log`"" +}