Files
MyGoNavi/internal/app/windows_update.ps1
Syngnat e1fb5b12d4 🐛 fix(update): 修复 Windows 在线更新中文路径失效
- 将 Windows 更新脚本统一为 PowerShell,并通过 Unicode 环境变量传递中文与特殊字符路径
- 保留当前可执行文件路径,补充替换重试、启动健康检查、失败回滚和严格 ZIP 选择
- 更新前复用未保存 SQL 确认流程,并延长退出兜底时间
- 新增 Windows 原生集成测试并接入 dev/release 构建
2026-07-10 13:31:34 +08:00

277 lines
10 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$Source = $env:GONAVI_UPDATE_SOURCE
$Target = $env:GONAVI_UPDATE_TARGET
$CurrentTarget = $env:GONAVI_UPDATE_CURRENT_TARGET
$StagedDir = $env:GONAVI_UPDATE_STAGED_DIR
$LogPath = $env:GONAVI_UPDATE_LOG_PATH
$HostProcessId = 0
$TargetOld = $null
$ReplacementPrepared = $false
$PreviousTargetBackedUp = $false
$TargetWriteStarted = $false
$LaunchSucceeded = $false
$HostExited = $false
$SourceMatchesTarget = $false
$RollbackSucceeded = $true
function Write-UpdateLog {
param([string]$Message)
if ([string]::IsNullOrWhiteSpace($LogPath)) {
return
}
try {
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
Add-Content -LiteralPath $LogPath -Value "[$timestamp] $Message" -Encoding UTF8
} catch {
# Logging must never hide the original updater error.
}
}
function Test-SamePath {
param(
[string]$Left,
[string]$Right
)
if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) {
return $false
}
return [StringComparer]::OrdinalIgnoreCase.Equals(
[IO.Path]::GetFullPath($Left),
[IO.Path]::GetFullPath($Right)
)
}
function Restore-PreviousTarget {
try {
if ($PreviousTargetBackedUp -and -not (Test-Path -LiteralPath $TargetOld -PathType Leaf)) {
Write-UpdateLog 'rollback failed: previous executable backup is missing'
return $false
}
if (($TargetWriteStarted -or $ReplacementPrepared) -and (Test-Path -LiteralPath $Target -PathType Leaf)) {
Remove-Item -LiteralPath $Target -Force
}
if ($PreviousTargetBackedUp -and (Test-Path -LiteralPath $TargetOld -PathType Leaf)) {
Move-Item -LiteralPath $TargetOld -Destination $Target -Force
}
return $true
} catch {
Write-UpdateLog ("rollback failed: " + $_.Exception.Message)
return $false
}
}
function Remove-UpdateArtifact {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return
}
try {
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Force -Recurse
}
} catch {
Write-UpdateLog ("cleanup failed for " + $Path + ": " + $_.Exception.Message)
}
}
function Select-PortableExecutable {
param(
[string]$ExtractDir,
[string]$TargetPath,
[string]$PackagePath
)
$ExecutableCandidates = @(Get-ChildItem -LiteralPath $ExtractDir -Recurse -File |
Where-Object { $_.Extension -ieq '.exe' })
if ($ExecutableCandidates.Count -eq 0) {
throw 'no executable found in portable zip; package retained for manual install'
}
$TargetFileName = [IO.Path]::GetFileName($TargetPath)
$ExactTargetMatches = @($ExecutableCandidates | Where-Object {
[string]::Equals($_.Name, $TargetFileName, [StringComparison]::OrdinalIgnoreCase)
})
if ($ExactTargetMatches.Count -eq 1) {
return $ExactTargetMatches[0].FullName
}
if ($ExactTargetMatches.Count -gt 1) {
throw 'ambiguous portable zip: multiple executables match current target filename; package retained for manual install'
}
$PackageExecutableName = [IO.Path]::GetFileNameWithoutExtension($PackagePath) + '.exe'
$PackageNameMatches = @($ExecutableCandidates | Where-Object {
[string]::Equals($_.Name, $PackageExecutableName, [StringComparison]::OrdinalIgnoreCase)
})
if ($PackageNameMatches.Count -eq 1) {
return $PackageNameMatches[0].FullName
}
if ($PackageNameMatches.Count -gt 1) {
throw 'ambiguous portable zip: multiple executables match package filename; package retained for manual install'
}
$GoNaviMatches = @($ExecutableCandidates | Where-Object {
$_.Name.StartsWith('GoNavi', [StringComparison]::OrdinalIgnoreCase)
})
if ($GoNaviMatches.Count -eq 1) {
return $GoNaviMatches[0].FullName
}
if ($ExecutableCandidates.Count -eq 1) {
return $ExecutableCandidates[0].FullName
}
throw ("ambiguous portable zip: found " + $ExecutableCandidates.Count + " executable candidates; package retained for manual install")
}
try {
foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $StagedDir, $LogPath)) {
if ([string]::IsNullOrWhiteSpace($requiredPath)) {
throw 'missing required updater path'
}
}
if (-not [int]::TryParse($env:GONAVI_UPDATE_PID, [ref]$HostProcessId) -or $HostProcessId -le 0) {
throw 'invalid host process id'
}
$TargetOld = $Target + '.old'
$TargetDir = [IO.Path]::GetDirectoryName($Target)
if ([string]::IsNullOrWhiteSpace($TargetDir) -or -not (Test-Path -LiteralPath $TargetDir -PathType Container)) {
throw 'target directory does not exist'
}
if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
throw 'source file not found'
}
Write-UpdateLog 'updater started'
$waitedSeconds = 0
while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue) {
if ($waitedSeconds -ge 90) {
throw 'host process still running after 90 seconds'
}
Start-Sleep -Seconds 1
$waitedSeconds++
}
$HostExited = $true
Write-UpdateLog 'host process exited'
Start-Sleep -Seconds 3
Write-UpdateLog 'cooldown finished, starting file replace'
$SourceExe = $Source
if ([string]::Equals([IO.Path]::GetExtension($Source), '.zip', [StringComparison]::OrdinalIgnoreCase)) {
$ExtractDir = [IO.Path]::Combine($StagedDir, '_extract')
if (Test-Path -LiteralPath $ExtractDir) {
Remove-Item -LiteralPath $ExtractDir -Recurse -Force
}
[IO.Directory]::CreateDirectory($ExtractDir) | Out-Null
Expand-Archive -LiteralPath $Source -DestinationPath $ExtractDir -Force
$SourceExe = Select-PortableExecutable -ExtractDir $ExtractDir -TargetPath $Target -PackagePath $Source
Write-UpdateLog ("selected portable executable: " + $SourceExe)
}
$SourceMatchesTarget = Test-SamePath $SourceExe $Target
if ($SourceMatchesTarget) {
Write-UpdateLog 'downloaded executable already at target path, skipping replace'
$ReplacementPrepared = $true
} else {
for ($attempt = 0; $attempt -lt 15; $attempt++) {
$PreviousTargetBackedUp = $false
$TargetWriteStarted = $false
try {
if (Test-Path -LiteralPath $TargetOld) {
Remove-Item -LiteralPath $TargetOld -Force
}
if (Test-Path -LiteralPath $Target -PathType Leaf) {
Move-Item -LiteralPath $Target -Destination $TargetOld -Force
$PreviousTargetBackedUp = $true
}
$TargetWriteStarted = $true
Copy-Item -LiteralPath $SourceExe -Destination $Target -Force
$ReplacementPrepared = $true
break
} catch {
Write-UpdateLog ("replace attempt " + ($attempt + 1) + " failed: " + $_.Exception.Message)
if ($PreviousTargetBackedUp -or $TargetWriteStarted) {
$RollbackSucceeded = Restore-PreviousTarget
if (-not $RollbackSucceeded) {
throw 'replace failed and previous executable could not be restored'
}
}
if ($attempt -ge 14) {
break
}
$waitSeconds = 1
if ($attempt -ge 8) {
$waitSeconds = 5
} elseif ($attempt -ge 5) {
$waitSeconds = 3
} elseif ($attempt -ge 2) {
$waitSeconds = 2
}
Start-Sleep -Seconds $waitSeconds
}
}
}
if (-not $ReplacementPrepared) {
throw 'replace failed after retries; package kept for manual install'
}
Write-UpdateLog ("launching target: " + $Target)
$NewProcess = Start-Process -FilePath $Target -WorkingDirectory $TargetDir -PassThru -ErrorAction Stop
Start-Sleep -Milliseconds 1500
$NewProcess.Refresh()
if ($NewProcess.HasExited) {
throw 'updated application exited immediately after launch'
}
$LaunchSucceeded = $true
Remove-UpdateArtifact $TargetOld
if (-not (Test-SamePath $CurrentTarget $Target)) {
Remove-UpdateArtifact $CurrentTarget
}
if (-not (Test-SamePath $Source $Target)) {
Remove-UpdateArtifact $Source
}
Write-UpdateLog 'update finished'
$CleanupCommand = 'Start-Sleep -Seconds 2; Remove-Item -LiteralPath $env:GONAVI_UPDATE_STAGED_DIR -Recurse -Force -ErrorAction SilentlyContinue'
$EncodedCleanupCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupCommand))
$CleanupWorkingDirectory = [IO.Path]::GetTempPath()
try {
Start-Process -FilePath 'powershell.exe' -WorkingDirectory $CleanupWorkingDirectory -WindowStyle Hidden -ArgumentList @(
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
$EncodedCleanupCommand
) -ErrorAction Stop | Out-Null
} catch {
Write-UpdateLog ("cleanup scheduler failed: " + $_.Exception.Message)
}
exit 0
} catch {
Write-UpdateLog ("updater failed: " + $_.Exception.Message)
if ($ReplacementPrepared -and -not $LaunchSucceeded -and -not $SourceMatchesTarget) {
$RollbackSucceeded = Restore-PreviousTarget
}
if ($HostExited -and -not $LaunchSucceeded -and -not $SourceMatchesTarget -and $RollbackSucceeded) {
try {
if (Test-Path -LiteralPath $CurrentTarget -PathType Leaf) {
$CurrentTargetDir = [IO.Path]::GetDirectoryName($CurrentTarget)
Start-Process -FilePath $CurrentTarget -WorkingDirectory $CurrentTargetDir -ErrorAction Stop | Out-Null
Write-UpdateLog 'previous application relaunched after update failure'
}
} catch {
Write-UpdateLog ("previous application relaunch failed: " + $_.Exception.Message)
}
}
exit 1
}