mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e7d96fe7 | ||
|
|
c5ef418bd9 | ||
|
|
1e6b61db85 | ||
|
|
48ce93b68e | ||
|
|
cddebbcf5a | ||
|
|
24b361b5b9 | ||
|
|
833c69305b | ||
|
|
858612fa8e | ||
|
|
f6a6f97e0b | ||
|
|
02547eeea6 | ||
|
|
8e39ea864f | ||
|
|
6343164b24 | ||
|
|
8e21364eed | ||
|
|
4f9feafbbe | ||
|
|
9cf3a15c89 | ||
|
|
e8d2575bc1 | ||
|
|
4b897831de |
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -68,10 +69,18 @@ jobs:
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
|
||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
|
||||
# Prefer modern WebKit package, fallback for older images.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -98,10 +107,14 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -132,6 +145,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Pack SDK and template packages
|
||||
shell: pwsh
|
||||
|
||||
3
.github/workflows/code-quality.yml
vendored
3
.github/workflows/code-quality.yml
vendored
@@ -25,12 +25,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
404
.github/workflows/release.yml
vendored
404
.github/workflows/release.yml
vendored
@@ -88,6 +88,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -108,7 +109,7 @@ jobs:
|
||||
|
||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||
|
||||
# AOT 单文件发布
|
||||
# AOT publish
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./$launcherPublishDir `
|
||||
@@ -126,7 +127,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 显示发布结果
|
||||
# 鏄剧ず鍙戝竷缁撴灉
|
||||
Write-Host "Launcher published to: $launcherPublishDir"
|
||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
@@ -134,7 +135,7 @@ jobs:
|
||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||
}
|
||||
|
||||
# 清理不必要的文件(AOT 单文件应该只有一个 exe)
|
||||
# Warn if unexpected extra files are produced
|
||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
||||
if ($files.Count -gt 1) {
|
||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
||||
@@ -219,8 +220,10 @@ jobs:
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y --no-progress
|
||||
- name: Install Inno Setup and 7z
|
||||
run: |
|
||||
choco install innosetup -y --no-progress
|
||||
choco install 7zip -y --no-progress
|
||||
shell: pwsh
|
||||
|
||||
- name: Build Installer
|
||||
@@ -314,207 +317,92 @@ jobs:
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Generate Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Build Signed FileMap Update Package
|
||||
if: matrix.self_contained == true
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$publishDir = "publish/windows-${{ matrix.arch }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$platform = "windows-$arch"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "delta-output"
|
||||
$outputDir = Join-Path "delta-output" $platform
|
||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
||||
$signScript = "scripts/Sign-FileMap.ps1"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
# --- Determine previous version and download its update.zip for diff ---
|
||||
$previousVersion = $null
|
||||
$previousAppPath = $null
|
||||
try {
|
||||
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
|
||||
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
|
||||
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
|
||||
if ($previousRelease) {
|
||||
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
|
||||
Write-Host "Previous release version: $previousVersion"
|
||||
|
||||
# Try to download update.zip from previous release for diff
|
||||
$prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1
|
||||
if ($prevUpdateZip) {
|
||||
Write-Host "Found update.zip in previous release - extracting for diff..."
|
||||
$prevZipDest = Join-Path $outputDir "prev-update.zip"
|
||||
Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers
|
||||
|
||||
$previousAppPath = Join-Path $outputDir "prev-app"
|
||||
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
|
||||
Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force
|
||||
Remove-Item -Path $prevZipDest -Force
|
||||
|
||||
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
|
||||
Write-Host "Extracted $prevFileCount files from previous version for diff"
|
||||
} else {
|
||||
Write-Host "No update.zip found in previous release - will generate full package"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Could not fetch previous release: $_"
|
||||
}
|
||||
|
||||
# --- Generate file manifest with diff against previous version ---
|
||||
Write-Host "Generating update package for version $version..."
|
||||
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
|
||||
$fileEntries = [System.Collections.ArrayList]::new()
|
||||
$changedFiles = [System.Collections.ArrayList]::new()
|
||||
$reusedCount = 0
|
||||
$addedCount = 0
|
||||
$replacedCount = 0
|
||||
$deletedCount = 0
|
||||
|
||||
# Build hash map of previous version files for quick lookup
|
||||
$prevHashMap = @{}
|
||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
||||
$prevFiles = Get-ChildItem -Path $previousAppPath -Recurse -File
|
||||
foreach ($pf in $prevFiles) {
|
||||
$relPath = $pf.FullName.Substring($previousAppPath.Length).TrimStart('\', '/').Replace('\', '/')
|
||||
if ($relPath -match '^\.(current|partial|destroy)$') { continue }
|
||||
$prevHashMap[$relPath] = (Get-FileHash -Path $pf.FullName -Algorithm SHA256).Hash.ToLower()
|
||||
}
|
||||
Write-Host "Previous version has $($prevHashMap.Count) files for comparison"
|
||||
}
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
# Skip deployment marker files
|
||||
if ($relativePath -match '^\.(current|partial|destroy)$') {
|
||||
continue
|
||||
}
|
||||
|
||||
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
if ($prevHashMap.ContainsKey($relativePath)) {
|
||||
$prevHash = $prevHashMap[$relativePath]
|
||||
if ($hash -eq $prevHash) {
|
||||
$fileEntries += @{ Path = $relativePath; Action = "reuse"; Sha256 = $hash }
|
||||
$reusedCount++
|
||||
} else {
|
||||
$fileEntries += @{ Path = $relativePath; Action = "replace"; Sha256 = $hash; ArchivePath = $relativePath }
|
||||
$changedFiles += $file
|
||||
$replacedCount++
|
||||
}
|
||||
$prevHashMap.Remove($relativePath)
|
||||
} else {
|
||||
$fileEntries += @{ Path = $relativePath; Action = "add"; Sha256 = $hash; ArchivePath = $relativePath }
|
||||
$changedFiles += $file
|
||||
$addedCount++
|
||||
}
|
||||
}
|
||||
|
||||
# Files in previous version but not in current = deleted
|
||||
foreach ($deletedPath in $prevHashMap.Keys) {
|
||||
$fileEntries += @{ Path = $deletedPath; Action = "delete" }
|
||||
$deletedCount++
|
||||
}
|
||||
|
||||
Write-Host "Delta summary: $reusedCount reused, $replacedCount replaced, $addedCount added, $deletedCount deleted"
|
||||
Write-Host "Changed files to include in update.zip: $($changedFiles.Count)"
|
||||
|
||||
$filesJson = @{
|
||||
FromVersion = $previousVersion
|
||||
ToVersion = $version
|
||||
Platform = "windows"
|
||||
Arch = "x64"
|
||||
Files = $fileEntries
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$filesJsonPath = Join-Path $outputDir "files.json"
|
||||
$filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
Write-Host "Generated files.json with $($fileEntries.Count) entries"
|
||||
|
||||
# Create update.zip with only changed files
|
||||
$tempDir = Join-Path $outputDir "temp_staging"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
foreach ($file in $changedFiles) {
|
||||
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
|
||||
$destPath = Join-Path $tempDir $relativePath
|
||||
$destDir = Split-Path -Parent $destPath
|
||||
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
|
||||
Copy-Item -Path $file.FullName -Destination $destPath -Force
|
||||
}
|
||||
|
||||
$updateZipPath = Join-Path $outputDir "update.zip"
|
||||
if ($changedFiles.Count -gt 0) {
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
||||
} else {
|
||||
# No changed files - create a minimal zip
|
||||
$emptyMarker = Join-Path $tempDir ".no-changes"
|
||||
Set-Content -Path $emptyMarker -Value ""
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
||||
}
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
Write-Host "Created update.zip: $([Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)) MB"
|
||||
|
||||
# Clean up previous version extraction
|
||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
||||
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Sign File Map
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$outputDir = "delta-output"
|
||||
$filesJsonPath = Join-Path $outputDir "files.json"
|
||||
$signaturePath = Join-Path $outputDir "files.json.sig"
|
||||
|
||||
if (-not (Test-Path $filesJsonPath)) {
|
||||
Write-Error "files.json not found at $filesJsonPath"
|
||||
if (-not (Test-Path $currentAppPath)) {
|
||||
Write-Error "Expected app directory not found: $currentAppPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
& $generateScript `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.PDC_SIGNING_KEY }}
|
||||
'@.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
|
||||
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
|
||||
exit 0
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
'@.Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
|
||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
public class RsaSigner {
|
||||
public static void Sign(string jsonPath, string keyPath, string sigPath) {
|
||||
var jsonBytes = File.ReadAllBytes(jsonPath);
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(keyPath));
|
||||
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
|
||||
}
|
||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
||||
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
||||
|
||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
||||
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
|
||||
$repoRsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$repoRsa.ImportFromPem($repoPublicKeyPem)
|
||||
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
|
||||
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
|
||||
if ($repoSpki -ne $derivedSpki) {
|
||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
||||
exit 1
|
||||
}
|
||||
"@
|
||||
|
||||
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
|
||||
Remove-Item -Path $privateKeyPath -Force
|
||||
& $signScript `
|
||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
||||
-PrivateKeyPath $privateKeyPath `
|
||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
||||
|
||||
Write-Host "Signed files.json -> files.json.sig"
|
||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Delta Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Upload Signed FileMap Update Package
|
||||
if: matrix.self_contained == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-delta-windows-x64
|
||||
name: release-update-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
delta-output/files.json
|
||||
delta-output/files.json.sig
|
||||
delta-output/update.zip
|
||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
|
||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
|
||||
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -546,10 +434,18 @@ jobs:
|
||||
libxrender1 libxkbcommon-x11-0 \
|
||||
clang zlib1g-dev
|
||||
|
||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
||||
|
||||
# Prefer modern WebKit package, fallback for older images.
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -712,6 +608,90 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Signed FileMap Update Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$platform = "linux-x64"
|
||||
$publishDir = "publish/linux-x64"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = Join-Path "delta-output" $platform
|
||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
||||
$signScript = "scripts/Sign-FileMap.ps1"
|
||||
|
||||
if (-not (Test-Path $currentAppPath)) {
|
||||
Write-Error "Expected app directory not found: $currentAppPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
& $generateScript `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.PDC_SIGNING_KEY }}
|
||||
'@.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
'@.Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
||||
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
||||
|
||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
||||
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
|
||||
$repoRsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$repoRsa.ImportFromPem($repoPublicKeyPem)
|
||||
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
|
||||
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
|
||||
if ($repoSpki -ne $derivedSpki) {
|
||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
& $signScript `
|
||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
||||
-PrivateKeyPath $privateKeyPath `
|
||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
||||
|
||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
||||
|
||||
- name: Upload Signed FileMap Update Package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-update-linux-x64
|
||||
path: |
|
||||
delta-output/linux-x64/files-linux-x64.json
|
||||
delta-output/linux-x64/files-linux-x64.json.sig
|
||||
delta-output/linux-x64/update-linux-x64.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -736,10 +716,14 @@ jobs:
|
||||
submodules: recursive
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: 'preview'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
@@ -910,8 +894,8 @@ jobs:
|
||||
mkdir -p release-files
|
||||
# Copy installers and packages
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
# Copy delta update files (files.json, files.json.sig, update.zip)
|
||||
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
|
||||
# Copy signed file-map incremental update assets
|
||||
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
@@ -924,6 +908,44 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Incremental Assets to S3 (optional)
|
||||
if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' }}
|
||||
env:
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_OBJECT_PREFIX: lanmountain/distribution-v1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${S3_ACCESS_KEY:-}" ] || [ -z "${S3_SECRET_KEY:-}" ]; then
|
||||
echo "S3 credentials are not configured. Skipping optional S3 upload step."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 -m pip install --upgrade awscli
|
||||
|
||||
mkdir -p release-update-assets
|
||||
find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \;
|
||||
|
||||
asset_count=$(find release-update-assets -type f | wc -l)
|
||||
if [ "$asset_count" -eq 0 ]; then
|
||||
echo "Error: no incremental update assets found for S3 upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
|
||||
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
|
||||
export AWS_DEFAULT_REGION="$S3_REGION"
|
||||
|
||||
version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/"
|
||||
latest_prefix="${S3_OBJECT_PREFIX}/latest/"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors
|
||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
@@ -945,12 +967,12 @@ jobs:
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
### Incremental Update (Windows x64)
|
||||
- **files.json** - Update manifest listing changed files
|
||||
- **files.json.sig** - RSA signature of the manifest
|
||||
- **update.zip** - Archive containing changed files
|
||||
### Incremental Update Assets
|
||||
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
|
||||
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
|
||||
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
|
||||
|
||||
Existing users: The app will automatically detect and apply the incremental update on next launch.
|
||||
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
|
||||
|
||||
### Linux
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -512,3 +512,5 @@ nul
|
||||
/*.deb
|
||||
/*.dmg
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
|
||||
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `release.yml` produces signed FileMap incremental assets for Windows x64/x86 and Linux x64.
|
||||
- [x] `release.yml` no longer depends on `vpk`/VeloPack packaging.
|
||||
- [x] Launcher update engine applies only signed FileMap payload path.
|
||||
- [x] Host update workflow no longer expects `releases.win.json`/`*.nupkg`.
|
||||
- [x] Update source setting includes `pdc` and preserves GitHub fallback behavior.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||
- [ ] Rollback verification report attached.
|
||||
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# PDC Incremental Update Migration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace VeloPack-based incremental packaging with a unified signed FileMap pipeline and prepare for PDC/S3 distribution compatibility, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||
|
||||
## Stage 1 (Completed in this round)
|
||||
|
||||
- Release workflow outputs signed FileMap incremental assets as the primary path:
|
||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||
- Launcher and host update runtime remove VeloPack branches and return to signed FileMap apply path.
|
||||
- Host update asset discovery supports platform-scoped names with fallback to legacy generic names.
|
||||
- Optional S3 sync publishes incremental assets in parallel with GitHub Release assets.
|
||||
|
||||
## Stage 2 (In Progress)
|
||||
|
||||
- Introduce PDC-compatible update source (`pdc`) with fallback to GitHub.
|
||||
- Add PDC metadata/latest/distribution API consumption abstraction.
|
||||
- Keep Launcher install/apply/rollback state machine unchanged.
|
||||
- Prepare `phainon.yml`-compatible release metadata for future PDCC integration.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` no longer contains VeloPack packaging steps.
|
||||
- Windows x64/x86 and Linux x64 release jobs all upload signed FileMap incremental assets.
|
||||
- Host auto-update can detect and download platform-matching signed FileMap assets.
|
||||
- Launcher `update apply` succeeds with signed FileMap payload and rollback behavior remains unchanged.
|
||||
- Optional S3 upload step works when S3 secrets/vars are configured.
|
||||
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Promote signed FileMap generation to release primary path.
|
||||
- [x] Output platform-scoped incremental assets for Windows x64/x86 and Linux x64.
|
||||
- [x] Remove launcher/runtime VeloPack branches.
|
||||
- [x] Update host asset discovery to platform-scoped signed FileMap naming.
|
||||
- [x] Add optional S3 sync for incremental assets.
|
||||
- [x] Extend update source values with `pdc`.
|
||||
- [x] Add PDC check fallback service skeleton in settings domain.
|
||||
- [ ] Add full PDC FileMap object-hash download/deploy path.
|
||||
- [ ] Add PDCC publish integration and `phainon.yml` CI publishing flow.
|
||||
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Checklist (Deprecated)
|
||||
|
||||
- [x] Spec marked as deprecated.
|
||||
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||
- [x] No release workflow dependency remains on VeloPack.
|
||||
15
.trae/specs/velopack-update-integration/spec.md
Normal file
15
.trae/specs/velopack-update-integration/spec.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# VeloPack Update Integration (Deprecated)
|
||||
|
||||
## Status
|
||||
|
||||
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||
|
||||
## Deprecation Reason
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
|
||||
## Migration Note
|
||||
|
||||
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.
|
||||
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Tasks (Deprecated)
|
||||
|
||||
- [x] Mark VeloPack integration spec as deprecated.
|
||||
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||
- [x] Remove VeloPack release workflow packaging steps.
|
||||
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||
23
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
23
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherResult))]
|
||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
@@ -1,8 +1,11 @@
|
||||
-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
|
||||
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
|
||||
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
|
||||
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
|
||||
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
|
||||
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----
|
||||
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||
FQiGowgqx0l5AgMBAAE=
|
||||
-----END RSA PUBLIC KEY-----
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
<!-- 允许 IL 警告 -->
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<!-- FluentAvaloniaUI 需要:启用反射序列化(AOT 兼容模式) -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
|
||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -91,11 +91,7 @@ internal static class Commands
|
||||
"check" => updateEngine.CheckPendingUpdate(),
|
||||
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
||||
"rollback" => updateEngine.RollbackLatest(),
|
||||
"download" => await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false),
|
||||
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
||||
_ => new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
@@ -106,6 +102,15 @@ internal static class Commands
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||
{
|
||||
return await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static LauncherResult ExecutePluginCommand(
|
||||
CommandContext context,
|
||||
PluginInstallerService pluginInstaller,
|
||||
@@ -149,10 +154,7 @@ internal static class Commands
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
|
||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(snapshotFile);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
|
||||
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
|
||||
{
|
||||
if (Directory.Exists(snapshot.SourceDirectory))
|
||||
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
|
||||
@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
|
||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||
{
|
||||
return config.HostPath;
|
||||
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
public required string AppRoot { get; set; }
|
||||
public required HostDiscoveryOptions Options { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
private class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
internal class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
|
||||
|
||||
// 3. 反序列化并回调
|
||||
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||
if (message is not null)
|
||||
{
|
||||
_onProgress(message);
|
||||
|
||||
@@ -9,6 +9,15 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
@@ -41,6 +50,18 @@ internal sealed class LauncherFlowCoordinator
|
||||
// 清理旧版本,保留至少3个版本
|
||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
|
||||
// 检测老版本安装(首次运行时)
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||
if (legacyInfo != null)
|
||||
{
|
||||
var migrationResult = await ShowMigrationPromptAsync(legacyInfo);
|
||||
// 无论用户选择什么,都继续启动流程
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}");
|
||||
}
|
||||
}
|
||||
|
||||
// 使用传入的 Splash 窗口或创建新的
|
||||
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
@@ -51,9 +72,23 @@ internal sealed class LauncherFlowCoordinator
|
||||
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
// 创建加载详情窗口(可选,用于显示详细加载状态)
|
||||
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
loadingDetailsWindow = new LoadingDetailsWindow();
|
||||
loadingDetailsWindow.Show();
|
||||
});
|
||||
}
|
||||
|
||||
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
|
||||
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// 加载状态管理
|
||||
var loadingState = new LoadingStateMessage();
|
||||
|
||||
// 启动 IPC 服务端监听主程序进度
|
||||
using var ipcServer = new LauncherIpcServer(msg =>
|
||||
{
|
||||
@@ -61,12 +96,29 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
try
|
||||
{
|
||||
// 更新加载状态
|
||||
loadingState = loadingState with
|
||||
{
|
||||
Stage = msg.Stage,
|
||||
OverallProgressPercent = msg.ProgressPercent,
|
||||
Message = msg.Message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 报告到 Splash 窗口
|
||||
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
|
||||
|
||||
// 主程序报告就绪后,关闭 Splash 窗口
|
||||
if (msg.Stage == StartupStage.Ready && splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
// 更新加载详情窗口
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
|
||||
// 主程序报告就绪后,关闭 Splash 窗口和加载详情窗口
|
||||
if (msg.Stage == StartupStage.Ready)
|
||||
{
|
||||
splashWindow.Close();
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
loadingDetailsWindow?.Close();
|
||||
hostReadyTcs.TrySetResult();
|
||||
}
|
||||
}
|
||||
@@ -121,20 +173,62 @@ internal sealed class LauncherFlowCoordinator
|
||||
// 维持 IPC 管道服务端供主程序报告启动进度。
|
||||
if (hostProcess is not null)
|
||||
{
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 如果主程序在 60 秒内未报告 Ready,也关闭 Splash 窗口作为超时保护
|
||||
var readyOrTimeout = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
Task.Delay(TimeSpan.FromSeconds(60)));
|
||||
|
||||
var processExitTask = hostProcess.WaitForExitAsync();
|
||||
|
||||
// 先等待就绪/超时,然后等待进程退出
|
||||
await readyOrTimeout;
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
|
||||
var readyOrTimeoutOrExit = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
var completedTask = await readyOrTimeoutOrExit;
|
||||
|
||||
// Host process exited before reporting Ready.
|
||||
if (completedTask == processExitTask)
|
||||
{
|
||||
var exitCode = hostProcess.ExitCode;
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||
|
||||
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
|
||||
exitCode,
|
||||
hostReadyTcs,
|
||||
splashWindow,
|
||||
loadingDetailsWindow).ConfigureAwait(false);
|
||||
if (recoveryResult is not null)
|
||||
{
|
||||
return recoveryResult;
|
||||
}
|
||||
|
||||
// Close Splash window for unrecoverable early exits.
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "host_crashed",
|
||||
Message = $"主程序异常退出,退出代码: {exitCode}"
|
||||
};
|
||||
}
|
||||
|
||||
// 如果 Splash 窗口仍然打开(超时情况),关闭它
|
||||
if (splashWindow.IsVisible)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Timeout waiting for Ready signal, closing splash window...");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
@@ -151,7 +245,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
});
|
||||
}
|
||||
|
||||
await processExitTask;
|
||||
// 继续等待主程序进程退出(如果它还在运行)
|
||||
if (!hostProcess.HasExited)
|
||||
{
|
||||
await processExitTask;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -200,6 +298,133 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
|
||||
int exitCode,
|
||||
TaskCompletionSource hostReadyTcs,
|
||||
SplashWindow splashWindow,
|
||||
LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activated_existing_instance",
|
||||
Message = "Detected existing running instance and activation was acknowledged."
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
|
||||
|
||||
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
|
||||
if (!retryLaunchResult.Success)
|
||||
{
|
||||
return retryLaunchResult;
|
||||
}
|
||||
|
||||
if (retryProcess is null)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_start_failed",
|
||||
Message = "Explicit activation retry failed because no host process was created."
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
|
||||
var retryExitTask = retryProcess.WaitForExitAsync();
|
||||
var retryCompleted = await Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
retryExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
|
||||
|
||||
if (retryCompleted == hostReadyTcs.Task)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_ready",
|
||||
Message = "Explicit activation retry succeeded and host reported Ready."
|
||||
};
|
||||
}
|
||||
|
||||
if (retryCompleted == retryExitTask)
|
||||
{
|
||||
var retryExitCode = retryProcess.ExitCode;
|
||||
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_redirected",
|
||||
Message = "Explicit activation retry redirected to the existing primary instance."
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_failed",
|
||||
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "activation_retry_timeout",
|
||||
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
|
||||
{
|
||||
// 优先使用自定义路径(调试模式选择的路径)
|
||||
@@ -236,36 +461,62 @@ internal sealed class LauncherFlowCoordinator
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
|
||||
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
|
||||
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0),
|
||||
// 避免子进程窗口创建成功但不可见的问题。
|
||||
var arguments = new System.Text.StringBuilder();
|
||||
|
||||
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
|
||||
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid)
|
||||
foreach (var arg in _context.RawArgs)
|
||||
{
|
||||
if (arg == _context.Command || arg == _context.SubCommand)
|
||||
continue;
|
||||
|
||||
if (arg.StartsWith("--"))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0) key = key[..equalsIndex];
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
// 通过命令行参数传递 IPC 连接信息(UseShellExecute=true 时不支持 EnvironmentVariables)
|
||||
if (arguments.Length > 0) arguments.Append(' ');
|
||||
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
|
||||
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = hostWorkingDir,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
|
||||
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
|
||||
foreach (var arg in _context.RawArgs)
|
||||
{
|
||||
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
|
||||
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
processStartInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
// 传递环境变量供 IPC 使用
|
||||
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承)
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||
Environment.ProcessId.ToString();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||
_deploymentLocator.GetAppRoot();
|
||||
|
||||
// 传递版本信息
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||
|
||||
var hostProcess = Process.Start(processStartInfo);
|
||||
Console.WriteLine(
|
||||
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
|
||||
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
|
||||
return (new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
@@ -341,6 +592,99 @@ internal sealed class LauncherFlowCoordinator
|
||||
return (result, customPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示迁移提示窗口
|
||||
/// </summary>
|
||||
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||
{
|
||||
MigrationPromptWindow? migrationWindow = null;
|
||||
|
||||
// 在 UI 线程创建并显示迁移提示窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
migrationWindow = new MigrationPromptWindow();
|
||||
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||
migrationWindow.Show();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow shown");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
if (migrationWindow is null)
|
||||
{
|
||||
Console.Error.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow is null, skipping migration prompt");
|
||||
return MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 等待用户选择
|
||||
MigrationResult result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await migrationWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] MigrationPromptWindow result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for migration choice: {ex.Message}");
|
||||
result = MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 安全关闭窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||
{
|
||||
migrationWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow closed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
|
||||
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
|
||||
/// </summary>
|
||||
internal sealed class LegacyVersionDetector
|
||||
{
|
||||
private const string LegacyAppName = "LanMountainDesktop";
|
||||
private const string LegacyExeName = "LanMountainDesktop.exe";
|
||||
|
||||
/// <summary>
|
||||
/// 检测是否存在老版本安装
|
||||
/// </summary>
|
||||
public static LegacyVersionInfo? DetectLegacyInstallation()
|
||||
{
|
||||
// 1. 检查注册表(安装版)
|
||||
var registryInfo = DetectFromRegistry();
|
||||
if (registryInfo != null)
|
||||
{
|
||||
return registryInfo;
|
||||
}
|
||||
|
||||
// 2. 检查常见安装目录
|
||||
var commonPaths = DetectFromCommonPaths();
|
||||
if (commonPaths != null)
|
||||
{
|
||||
return commonPaths;
|
||||
}
|
||||
|
||||
// 3. 检查便携版位置
|
||||
var portableInfo = DetectPortableInstallation();
|
||||
if (portableInfo != null)
|
||||
{
|
||||
return portableInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从注册表检测安装信息
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectFromRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
|
||||
using var key = Registry.LocalMachine.OpenSubKey(
|
||||
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||
|
||||
if (key != null)
|
||||
{
|
||||
var installLocation = key.GetValue("InstallLocation") as string;
|
||||
var displayVersion = key.GetValue("DisplayVersion") as string;
|
||||
var uninstallString = key.GetValue("UninstallString") as string;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = displayVersion ?? "0.8.x",
|
||||
InstallPath = installLocation,
|
||||
UninstallCommand = uninstallString,
|
||||
InstallType = LegacyInstallType.Registry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 HKCU(用户级安装)
|
||||
using var userKey = Registry.CurrentUser.OpenSubKey(
|
||||
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||
|
||||
if (userKey != null)
|
||||
{
|
||||
var installLocation = userKey.GetValue("InstallLocation") as string;
|
||||
var displayVersion = userKey.GetValue("DisplayVersion") as string;
|
||||
var uninstallString = userKey.GetValue("UninstallString") as string;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = displayVersion ?? "0.8.x",
|
||||
InstallPath = installLocation,
|
||||
UninstallCommand = uninstallString,
|
||||
InstallType = LegacyInstallType.Registry
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从常见安装路径检测
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectFromCommonPaths()
|
||||
{
|
||||
var commonPaths = new[]
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
|
||||
};
|
||||
|
||||
foreach (var path in commonPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
// 检查是否存在老版本的特征文件(没有 app-* 目录)
|
||||
var exePath = Path.Combine(path, LegacyExeName);
|
||||
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
|
||||
|
||||
if (File.Exists(exePath) && !hasAppDirs)
|
||||
{
|
||||
// 尝试读取版本信息
|
||||
var version = TryGetFileVersion(exePath);
|
||||
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = version ?? "0.8.x",
|
||||
InstallPath = path,
|
||||
UninstallCommand = FindUninstaller(path),
|
||||
InstallType = LegacyInstallType.CommonPath
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测便携版安装
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectPortableInstallation()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查启动器所在目录的父目录(便携版常见布局)
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||
|
||||
if (Directory.Exists(parentDir))
|
||||
{
|
||||
var exePath = Path.Combine(parentDir, LegacyExeName);
|
||||
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
|
||||
|
||||
// 如果存在 exe 且没有 app-* 目录,可能是老版本
|
||||
if (File.Exists(exePath) && !hasAppDirs)
|
||||
{
|
||||
var version = TryGetFileVersion(exePath);
|
||||
|
||||
// 检查是否真的是老版本(通过文件版本或特定标记)
|
||||
if (IsLegacyVersion(version))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = version ?? "0.8.x",
|
||||
InstallPath = parentDir,
|
||||
UninstallCommand = null, // 便携版没有卸载程序
|
||||
InstallType = LegacyInstallType.Portable
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找卸载程序
|
||||
/// </summary>
|
||||
private static string? FindUninstaller(string installPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 常见的卸载程序命名
|
||||
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
|
||||
|
||||
foreach (var name in uninstallerNames)
|
||||
{
|
||||
var path = Path.Combine(installPath, name);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件版本
|
||||
/// </summary>
|
||||
private static string? TryGetFileVersion(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
|
||||
return versionInfo.FileVersion;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否为老版本(版本号 < 1.0.0)
|
||||
/// </summary>
|
||||
private static bool IsLegacyVersion(string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return true; // 无法确定版本时,保守认为是老版本
|
||||
}
|
||||
|
||||
if (Version.TryParse(version.Split(' ')[0], out var v))
|
||||
{
|
||||
return v.Major < 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开卸载界面
|
||||
/// </summary>
|
||||
public static void OpenUninstallInterface(LegacyVersionInfo info)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
|
||||
{
|
||||
// 有卸载命令,直接执行
|
||||
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||
var fileName = parts[0].Trim('"');
|
||||
var arguments = parts.Length > 1 ? parts[1] : "";
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas" // 请求管理员权限
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有卸载命令,打开系统卸载面板
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "appwiz.cpl",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
|
||||
|
||||
// 兜底:打开系统卸载面板
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "appwiz.cpl",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在资源管理器中显示老版本位置
|
||||
/// </summary>
|
||||
public static void ShowInExplorer(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 老版本信息
|
||||
/// </summary>
|
||||
public class LegacyVersionInfo
|
||||
{
|
||||
public string Version { get; set; } = "0.8.x";
|
||||
public string InstallPath { get; set; } = "";
|
||||
public string? UninstallCommand { get; set; }
|
||||
public LegacyInstallType InstallType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 老版本安装类型
|
||||
/// </summary>
|
||||
public enum LegacyInstallType
|
||||
{
|
||||
Registry, // 注册表安装版
|
||||
CommonPath, // 常见路径安装
|
||||
Portable // 便携版
|
||||
}
|
||||
@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
|
||||
using var stream = entries[0].Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||
|
||||
@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(pendingPath);
|
||||
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
|
||||
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
|
||||
var failures = new List<string>();
|
||||
var succeeded = new List<PendingUpgrade>();
|
||||
|
||||
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
|
||||
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
internal sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
|
||||
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
internal sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
||||
if (fileMap is null)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
|
||||
@@ -137,7 +137,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
|
||||
var fileMapText = await File.ReadAllTextAsync(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
||||
if (fileMap is null || fileMap.Files.Count == 0)
|
||||
{
|
||||
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
||||
@@ -438,7 +438,7 @@ internal sealed class UpdateEngineService
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
|
||||
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
|
||||
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
|
||||
{
|
||||
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
|
||||
@@ -656,10 +656,7 @@ internal sealed class UpdateEngineService
|
||||
|
||||
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
private static LauncherResult Failed(string stage, string code, string message)
|
||||
|
||||
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
@@ -0,0 +1,234 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
||||
Title="LanMountain Desktop - Loading Details"
|
||||
Width="600"
|
||||
Height="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
CanResize="True"
|
||||
MinWidth="500"
|
||||
MinHeight="400"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Starting LanMountain Desktop"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
<TextBlock x:Name="SubtitleText"
|
||||
Text="Initializing..."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1"
|
||||
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="12,6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PercentText"
|
||||
Text="0%"
|
||||
FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" Margin="16,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ProgressBar x:Name="OverallProgressBar"
|
||||
Grid.Row="0"
|
||||
Height="8"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
CornerRadius="4"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16,12"
|
||||
Margin="0,0,0,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
||||
Width="40"
|
||||
Height="40"
|
||||
CornerRadius="20"
|
||||
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="CurrentItemIcon"
|
||||
Text=""
|
||||
FontSize="20"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="CurrentItemName"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="Initializing..."
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
|
||||
<TextBlock x:Name="CurrentItemDescription"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="Preparing components"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||
<ProgressBar x:Name="CurrentItemProgress"
|
||||
Height="4"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
CornerRadius="2"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="Loading Items"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock x:Name="CompletedCountText"
|
||||
Grid.Column="1"
|
||||
Text="0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,0,4,0"/>
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="Done"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Margin="8,0,8,8">
|
||||
<ItemsControl x:Name="LoadingItemsList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="4,3"
|
||||
Opacity="{Binding Opacity}">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding StatusIcon}"
|
||||
FontSize="14"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{Binding StatusColor}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Name}"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding ProgressText}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<Border Grid.Column="3"
|
||||
Background="{Binding TypeBackground}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding TypeLabel}"
|
||||
FontSize="11"
|
||||
Foreground="{Binding TypeForeground}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="ErrorPanel"
|
||||
Grid.Row="2"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="12,10"
|
||||
Margin="16,0,16,12"
|
||||
IsVisible="False">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text=""
|
||||
FontSize="16"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="ErrorText"
|
||||
Grid.Column="1"
|
||||
Text="An error occurred while loading."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="VersionText"
|
||||
Grid.Column="0"
|
||||
Text="v1.0.0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="DetailsButton"
|
||||
Content="Details"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="CancelButton"
|
||||
Content="Cancel"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 加载详情窗口 - 显示详细的加载状态和进度
|
||||
/// </summary>
|
||||
public partial class LoadingDetailsWindow : Window
|
||||
{
|
||||
private readonly ObservableCollection<LoadingItemViewModel> _items = new();
|
||||
private readonly DispatcherTimer _updateTimer;
|
||||
private DateTimeOffset _startTime;
|
||||
|
||||
public LoadingDetailsWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 初始化列表
|
||||
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
|
||||
if (itemsList != null)
|
||||
{
|
||||
itemsList.ItemsSource = _items;
|
||||
}
|
||||
|
||||
// 创建更新定时器
|
||||
_updateTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
_updateTimer.Tick += OnUpdateTimerTick;
|
||||
|
||||
_startTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成
|
||||
/// </summary>
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
_updateTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口关闭
|
||||
/// </summary>
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新加载状态
|
||||
/// </summary>
|
||||
public void UpdateLoadingState(LoadingStateMessage state)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 更新标题和副标题
|
||||
UpdateHeader(state);
|
||||
|
||||
// 更新整体进度
|
||||
UpdateOverallProgress(state);
|
||||
|
||||
// 更新当前活动项
|
||||
UpdateCurrentItem(state);
|
||||
|
||||
// 更新列表
|
||||
UpdateItemsList(state);
|
||||
|
||||
// 更新错误信息
|
||||
UpdateErrorPanel(state);
|
||||
|
||||
// 更新完成计数
|
||||
UpdateCompletedCount(state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LoadingDetailsWindow] Error updating state: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新标题
|
||||
/// </summary>
|
||||
private void UpdateHeader(LoadingStateMessage state)
|
||||
{
|
||||
var subtitleText = this.FindControl<TextBlock>("SubtitleText");
|
||||
if (subtitleText != null)
|
||||
{
|
||||
subtitleText.Text = GetStageDescription(state.Stage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新整体进度
|
||||
/// </summary>
|
||||
private void UpdateOverallProgress(LoadingStateMessage state)
|
||||
{
|
||||
var progressBar = this.FindControl<ProgressBar>("OverallProgressBar");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
|
||||
if (progressBar != null)
|
||||
{
|
||||
progressBar.Value = state.OverallProgressPercent;
|
||||
}
|
||||
|
||||
if (percentText != null)
|
||||
{
|
||||
percentText.Text = $"{state.OverallProgressPercent}%";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前活动项
|
||||
/// </summary>
|
||||
private void UpdateCurrentItem(LoadingStateMessage state)
|
||||
{
|
||||
var currentItem = state.ActiveItems.FirstOrDefault();
|
||||
if (currentItem == null) return;
|
||||
|
||||
var nameText = this.FindControl<TextBlock>("CurrentItemName");
|
||||
var descText = this.FindControl<TextBlock>("CurrentItemDescription");
|
||||
var progressBar = this.FindControl<ProgressBar>("CurrentItemProgress");
|
||||
var iconText = this.FindControl<TextBlock>("CurrentItemIcon");
|
||||
|
||||
if (nameText != null)
|
||||
{
|
||||
nameText.Text = currentItem.Name;
|
||||
}
|
||||
|
||||
if (descText != null)
|
||||
{
|
||||
descText.Text = currentItem.Message ?? GetItemDescription(currentItem);
|
||||
}
|
||||
|
||||
if (progressBar != null)
|
||||
{
|
||||
progressBar.Value = currentItem.ProgressPercent;
|
||||
}
|
||||
|
||||
if (iconText != null)
|
||||
{
|
||||
iconText.Text = GetItemIcon(currentItem.Type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新列表
|
||||
/// </summary>
|
||||
private void UpdateItemsList(LoadingStateMessage state)
|
||||
{
|
||||
// 同步列表项
|
||||
foreach (var item in state.ActiveItems)
|
||||
{
|
||||
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.UpdateFrom(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
_items.Add(new LoadingItemViewModel(item));
|
||||
}
|
||||
}
|
||||
|
||||
// 移除已完成的项(保留最近完成的5个)
|
||||
var completedItems = _items.Where(i => i.State == LoadingState.Completed).ToList();
|
||||
if (completedItems.Count > 5)
|
||||
{
|
||||
var itemsToRemove = completedItems.OrderBy(i => i.CompletedTime).Take(completedItems.Count - 5);
|
||||
foreach (var item in itemsToRemove)
|
||||
{
|
||||
_items.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
|
||||
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
|
||||
_items.Clear();
|
||||
foreach (var item in sortedItems)
|
||||
{
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新错误面板
|
||||
/// </summary>
|
||||
private void UpdateErrorPanel(LoadingStateMessage state)
|
||||
{
|
||||
var errorPanel = this.FindControl<Border>("ErrorPanel");
|
||||
var errorText = this.FindControl<TextBlock>("ErrorText");
|
||||
|
||||
if (errorPanel != null)
|
||||
{
|
||||
errorPanel.IsVisible = state.HasErrors;
|
||||
}
|
||||
|
||||
if (errorText != null && state.ErrorMessages?.Any() == true)
|
||||
{
|
||||
errorText.Text = string.Join("\n", state.ErrorMessages.Take(3));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新完成计数
|
||||
/// </summary>
|
||||
private void UpdateCompletedCount(LoadingStateMessage state)
|
||||
{
|
||||
var countText = this.FindControl<TextBlock>("CompletedCountText");
|
||||
if (countText != null)
|
||||
{
|
||||
countText.Text = state.CompletedCount.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时更新
|
||||
/// </summary>
|
||||
private void OnUpdateTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
// 可以在这里添加时间显示等实时更新
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取阶段描述
|
||||
/// </summary>
|
||||
private static string GetStageDescription(StartupStage stage) => stage switch
|
||||
{
|
||||
StartupStage.Initializing => "正在初始化系统...",
|
||||
StartupStage.LoadingSettings => "正在加载设置...",
|
||||
StartupStage.LoadingPlugins => "正在加载插件...",
|
||||
StartupStage.InitializingUI => "正在初始化界面...",
|
||||
StartupStage.Ready => "加载完成",
|
||||
_ => "正在加载..."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取项描述
|
||||
/// </summary>
|
||||
private static string GetItemDescription(LoadingItem item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Description))
|
||||
return item.Description;
|
||||
|
||||
return item.Type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "正在加载插件...",
|
||||
LoadingItemType.Component => "正在加载组件...",
|
||||
LoadingItemType.Resource => "正在加载资源...",
|
||||
LoadingItemType.Data => "正在加载数据...",
|
||||
LoadingItemType.Network => "正在下载...",
|
||||
_ => "正在处理..."
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取项图标
|
||||
/// </summary>
|
||||
private static string GetItemIcon(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "\uE768",
|
||||
LoadingItemType.Component => "\uE7C4",
|
||||
LoadingItemType.Resource => "\uE7C5",
|
||||
LoadingItemType.Data => "\uE7C6",
|
||||
LoadingItemType.Network => "\uE774",
|
||||
LoadingItemType.Settings => "\uE713",
|
||||
LoadingItemType.System => "\uE7C7",
|
||||
_ => "\uE768"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取状态优先级
|
||||
/// </summary>
|
||||
private static int GetStatePriority(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.InProgress => 0,
|
||||
LoadingState.Pending => 1,
|
||||
LoadingState.Completed => 2,
|
||||
LoadingState.Failed => 3,
|
||||
LoadingState.Timeout => 4,
|
||||
LoadingState.Cancelled => 5,
|
||||
_ => 6
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项视图模型
|
||||
/// </summary>
|
||||
public class LoadingItemViewModel : INotifyPropertyChanged
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Name { get; private set; }
|
||||
public LoadingItemType Type { get; private set; }
|
||||
public LoadingState State { get; private set; }
|
||||
public int ProgressPercent { get; private set; }
|
||||
public DateTimeOffset? CompletedTime { get; private set; }
|
||||
|
||||
public string StatusIcon => GetStatusIcon(State);
|
||||
public IBrush StatusColor => GetStatusColor(State);
|
||||
public string ProgressText => State == LoadingState.Completed ? "完成" : $"{ProgressPercent}%";
|
||||
public string TypeLabel => GetTypeLabel(Type);
|
||||
public IBrush TypeBackground => GetTypeBackground(Type);
|
||||
public IBrush TypeForeground => GetTypeForeground(Type);
|
||||
public double Opacity => State == LoadingState.Completed ? 0.6 : 1.0;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public LoadingItemViewModel(LoadingItem item)
|
||||
{
|
||||
Id = item.Id;
|
||||
UpdateFrom(item);
|
||||
}
|
||||
|
||||
public void UpdateFrom(LoadingItem item)
|
||||
{
|
||||
Name = item.Name;
|
||||
Type = item.Type;
|
||||
State = item.State;
|
||||
ProgressPercent = item.ProgressPercent;
|
||||
|
||||
if (State == LoadingState.Completed && !CompletedTime.HasValue)
|
||||
{
|
||||
CompletedTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
|
||||
}
|
||||
|
||||
private static string GetStatusIcon(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.Pending => "\uE7C3",
|
||||
LoadingState.InProgress => "\uE768",
|
||||
LoadingState.Completed => "\uE73E",
|
||||
LoadingState.Failed => "\uE783",
|
||||
LoadingState.Timeout => "\uE71A",
|
||||
LoadingState.Cancelled => "\uE711",
|
||||
_ => "\uE7C3"
|
||||
};
|
||||
|
||||
private static IBrush GetStatusColor(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.Pending => new SolidColorBrush(Colors.Gray),
|
||||
LoadingState.InProgress => new SolidColorBrush(Colors.DodgerBlue),
|
||||
LoadingState.Completed => new SolidColorBrush(Colors.Green),
|
||||
LoadingState.Failed => new SolidColorBrush(Colors.Red),
|
||||
LoadingState.Timeout => new SolidColorBrush(Colors.Orange),
|
||||
LoadingState.Cancelled => new SolidColorBrush(Colors.Gray),
|
||||
_ => new SolidColorBrush(Colors.Gray)
|
||||
};
|
||||
|
||||
private static string GetTypeLabel(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "插件",
|
||||
LoadingItemType.Component => "组件",
|
||||
LoadingItemType.Resource => "资源",
|
||||
LoadingItemType.Data => "数据",
|
||||
LoadingItemType.Network => "网络",
|
||||
LoadingItemType.Settings => "设置",
|
||||
LoadingItemType.System => "系统",
|
||||
_ => "其他"
|
||||
};
|
||||
|
||||
private static IBrush GetTypeBackground(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#E3F2FD")),
|
||||
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#F3E5F5")),
|
||||
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#E8F5E9")),
|
||||
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#FFF3E0")),
|
||||
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#E0F7FA")),
|
||||
_ => new SolidColorBrush(Color.Parse("#F5F5F5"))
|
||||
};
|
||||
|
||||
private static IBrush GetTypeForeground(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#1976D2")),
|
||||
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#7B1FA2")),
|
||||
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#388E3C")),
|
||||
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#F57C00")),
|
||||
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#0097A7")),
|
||||
_ => new SolidColorBrush(Color.Parse("#616161"))
|
||||
};
|
||||
}
|
||||
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
@@ -0,0 +1,149 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="360"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.MigrationPromptWindow"
|
||||
x:DataType="views:MigrationPromptWindow"
|
||||
Title="阑山桌面 - 版本迁移"
|
||||
Width="520"
|
||||
Height="360"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:MigrationPromptWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:信息图标 -->
|
||||
<Border Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text=""
|
||||
FontSize="24"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 右侧:内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="12">
|
||||
<!-- 标题 -->
|
||||
<TextBlock Text="检测到旧版本"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<TextBlock x:Name="DescriptionText"
|
||||
Text="检测到您的系统中安装了旧版本的阑山桌面(0.8.4)。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 老版本信息卡片 -->
|
||||
<Border Margin="0,8,0,0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||
<!-- 版本号 -->
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="版本:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock x:Name="VersionText"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="0.8.4"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,0,0,0"/>
|
||||
|
||||
<!-- 安装路径 -->
|
||||
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||
Text="位置:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
<TextBlock x:Name="PathText"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="C:\Program Files\LanMountainDesktop"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="8,4,0,0"/>
|
||||
|
||||
<!-- 安装类型 -->
|
||||
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||
Text="类型:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
<TextBlock x:Name="TypeText"
|
||||
Grid.Row="2" Grid.Column="1"
|
||||
Text="安装版"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,4,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<TextBlock Text="卸载旧版本不会影响新版本的使用,您的个人数据将保留。"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!-- 左侧:查看位置按钮 -->
|
||||
<Button x:Name="ShowLocationButton"
|
||||
Grid.Column="0"
|
||||
Content="查看位置"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="SkipButton"
|
||||
Content="暂不处理"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="UninstallButton"
|
||||
Content="卸载旧版本"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 迁移提示窗口 - 提示用户卸载旧版本
|
||||
/// </summary>
|
||||
public partial class MigrationPromptWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<MigrationResult> _completionSource = new();
|
||||
private LegacyVersionInfo? _legacyInfo;
|
||||
|
||||
public MigrationPromptWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeEventHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置老版本信息
|
||||
/// </summary>
|
||||
public void SetLegacyInfo(LegacyVersionInfo info)
|
||||
{
|
||||
_legacyInfo = info;
|
||||
|
||||
// 更新 UI
|
||||
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||
var pathText = this.FindControl<TextBlock>("PathText");
|
||||
var typeText = this.FindControl<TextBlock>("TypeText");
|
||||
var descriptionText = this.FindControl<TextBlock>("DescriptionText");
|
||||
|
||||
if (versionText != null)
|
||||
{
|
||||
versionText.Text = info.Version;
|
||||
}
|
||||
|
||||
if (pathText != null)
|
||||
{
|
||||
pathText.Text = info.InstallPath;
|
||||
}
|
||||
|
||||
if (typeText != null)
|
||||
{
|
||||
typeText.Text = info.InstallType switch
|
||||
{
|
||||
LegacyInstallType.Registry => "安装版",
|
||||
LegacyInstallType.Portable => "便携版",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
if (descriptionText != null)
|
||||
{
|
||||
descriptionText.Text = $"检测到您的系统中安装了旧版本的阑山桌面({info.Version})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化事件处理程序
|
||||
/// </summary>
|
||||
private void InitializeEventHandlers()
|
||||
{
|
||||
var showLocationButton = this.FindControl<Button>("ShowLocationButton");
|
||||
var skipButton = this.FindControl<Button>("SkipButton");
|
||||
var uninstallButton = this.FindControl<Button>("UninstallButton");
|
||||
|
||||
if (showLocationButton != null)
|
||||
{
|
||||
showLocationButton.Click += OnShowLocationClick;
|
||||
}
|
||||
|
||||
if (skipButton != null)
|
||||
{
|
||||
skipButton.Click += OnSkipClick;
|
||||
}
|
||||
|
||||
if (uninstallButton != null)
|
||||
{
|
||||
uninstallButton.Click += OnUninstallClick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查看位置按钮点击
|
||||
/// </summary>
|
||||
private void OnShowLocationClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_legacyInfo != null)
|
||||
{
|
||||
LegacyVersionDetector.ShowInExplorer(_legacyInfo.InstallPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跳过按钮点击
|
||||
/// </summary>
|
||||
private void OnSkipClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卸载按钮点击
|
||||
/// </summary>
|
||||
private void OnUninstallClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_legacyInfo != null)
|
||||
{
|
||||
LegacyVersionDetector.OpenUninstallInterface(_legacyInfo);
|
||||
}
|
||||
|
||||
_completionSource.TrySetResult(MigrationResult.UninstallOpened);
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<MigrationResult> WaitForChoiceAsync()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口关闭事件
|
||||
/// </summary>
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// 如果还没有完成,标记为跳过
|
||||
if (!_completionSource.Task.IsCompleted)
|
||||
{
|
||||
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||
}
|
||||
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 迁移结果
|
||||
/// </summary>
|
||||
public enum MigrationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户选择跳过
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// 已打开卸载界面
|
||||
/// </summary>
|
||||
UninstallOpened
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Standardized host process exit codes consumed by the launcher.
|
||||
/// </summary>
|
||||
public static class HostExitCodes
|
||||
{
|
||||
public const int Success = 0;
|
||||
|
||||
// Secondary instance activated the existing primary instance successfully.
|
||||
public const int SecondaryActivationSucceeded = 12;
|
||||
|
||||
// Secondary instance failed to activate the existing primary instance.
|
||||
public const int SecondaryActivationFailed = 13;
|
||||
|
||||
// Restart relaunch couldn't acquire the single-instance lock in time.
|
||||
public const int RestartLockNotAcquired = 14;
|
||||
}
|
||||
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal file
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public enum LoadingItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统初始化
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// 设置加载
|
||||
/// </summary>
|
||||
Settings,
|
||||
|
||||
/// <summary>
|
||||
/// 插件
|
||||
/// </summary>
|
||||
Plugin,
|
||||
|
||||
/// <summary>
|
||||
/// 组件
|
||||
/// </summary>
|
||||
Component,
|
||||
|
||||
/// <summary>
|
||||
/// 资源
|
||||
/// </summary>
|
||||
Resource,
|
||||
|
||||
/// <summary>
|
||||
/// 数据
|
||||
/// </summary>
|
||||
Data,
|
||||
|
||||
/// <summary>
|
||||
/// 网络请求
|
||||
/// </summary>
|
||||
Network,
|
||||
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态
|
||||
/// </summary>
|
||||
public enum LoadingState
|
||||
{
|
||||
/// <summary>
|
||||
/// 等待中
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// 进行中
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// 超时
|
||||
/// </summary>
|
||||
Timeout
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项信息
|
||||
/// </summary>
|
||||
public record LoadingItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载项唯一标识
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public LoadingItemType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项名称
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项描述
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前状态
|
||||
/// </summary>
|
||||
public LoadingState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(当 State 为 Failed 时)
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预计剩余时间(秒)
|
||||
/// </summary>
|
||||
public int? EstimatedRemainingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem>? Children { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外数据
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态更新消息
|
||||
/// </summary>
|
||||
public record LoadingStateMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比 (0-100)
|
||||
/// </summary>
|
||||
public int OverallProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前活动的加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem> ActiveItems { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 已完成的加载项数量
|
||||
/// </summary>
|
||||
public int CompletedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总加载项数量
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
public bool HasErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误消息列表
|
||||
/// </summary>
|
||||
public List<string>? ErrorMessages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 详细的加载进度消息(用于实时更新)
|
||||
/// </summary>
|
||||
public record DetailedProgressMessage : StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前加载项
|
||||
/// </summary>
|
||||
public LoadingItem? CurrentItem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem>? AllItems { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为主要更新
|
||||
/// </summary>
|
||||
public bool IsMajorUpdate { get; init; }
|
||||
}
|
||||
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SingleInstanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
primary.StartActivationListener(() => activated.TrySetResult());
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
|
||||
Assert.True(acknowledged);
|
||||
Assert.Null(failureReason);
|
||||
|
||||
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(activated.Task, completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
|
||||
|
||||
Assert.False(acknowledged);
|
||||
Assert.False(string.IsNullOrWhiteSpace(failureReason));
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateService(string mutexName, string pipeName)
|
||||
{
|
||||
var ctor = typeof(SingleInstanceService).GetConstructor(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
[typeof(string), typeof(string)],
|
||||
modifiers: null);
|
||||
|
||||
Assert.NotNull(ctor);
|
||||
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
|
||||
{
|
||||
SingleInstanceService? created = null;
|
||||
Exception? creationError = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
created = CreateService(mutexName, pipeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
creationError = ex;
|
||||
}
|
||||
});
|
||||
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (creationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
|
||||
}
|
||||
|
||||
Assert.NotNull(created);
|
||||
return created!;
|
||||
}
|
||||
|
||||
private static void MarkAsSecondaryForTest(SingleInstanceService service)
|
||||
{
|
||||
var ownsMutexField = typeof(SingleInstanceService).GetField(
|
||||
"_ownsMutex",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(ownsMutexField);
|
||||
ownsMutexField!.SetValue(service, false);
|
||||
Assert.False(service.IsPrimaryInstance);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Loading;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -74,6 +75,10 @@ public partial class App : Application
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
@@ -139,7 +144,7 @@ public partial class App : Application
|
||||
EnsureNotificationService();
|
||||
}
|
||||
|
||||
public override async void OnFrameworkInitializationCompleted()
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
@@ -149,12 +154,8 @@ public partial class App : Application
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
|
||||
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
|
||||
await InitializeLauncherIpcAsync();
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
@@ -163,6 +164,10 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
|
||||
// 使用 fire-and-forget 模式,不阻塞主流程
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
}
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
@@ -178,7 +183,18 @@ public partial class App : Application
|
||||
if (connected)
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||
|
||||
// 初始化加载状态管理器
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
// 注册系统初始化加载项
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
|
||||
_loadingStateManager.StartItem("system.init", "已连接启动器");
|
||||
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -213,6 +229,41 @@ public partial class App : Application
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
|
||||
/// 用于 Ready 等关键状态报告
|
||||
/// </summary>
|
||||
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDesignTimeTheme()
|
||||
{
|
||||
RequestedThemeVariant = ThemeVariant.Light;
|
||||
@@ -241,16 +292,20 @@ public partial class App : Application
|
||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
},
|
||||
() =>
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
},
|
||||
OnDesktopLifetimeExit,
|
||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||
StartWeatherLocationRefreshIfNeeded);
|
||||
_desktopShellHost.Initialize(this);
|
||||
}
|
||||
|
||||
private void OnDesktopLifetimeExit()
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
|
||||
ScheduleForcedProcessTermination("DesktopLifetimeExit");
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
@@ -610,70 +665,102 @@ public partial class App : Application
|
||||
|
||||
private void ActivateMainWindow()
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
|
||||
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
|
||||
|
||||
try
|
||||
{
|
||||
var restored = Dispatcher.UIThread.CheckAccess()
|
||||
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
|
||||
: Dispatcher.UIThread.InvokeAsync(
|
||||
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
|
||||
DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||
|
||||
if (!restored)
|
||||
{
|
||||
throw new InvalidOperationException("Main window restore failed in activation callback.");
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||
}
|
||||
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
||||
|
||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Hide();
|
||||
}
|
||||
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.PrepareEnterAnimation();
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTransparentOverlayWindow()
|
||||
{
|
||||
@@ -836,6 +923,57 @@ public partial class App : Application
|
||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void ReleaseSingleInstanceAfterExit(string source)
|
||||
{
|
||||
if (_singleInstanceReleased)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_singleInstanceReleased = true;
|
||||
var singleInstance = CurrentSingleInstanceService;
|
||||
CurrentSingleInstanceService = null;
|
||||
if (singleInstance is null)
|
||||
{
|
||||
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
singleInstance.Dispose();
|
||||
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleForcedProcessTermination(string source)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(8)).ConfigureAwait(false);
|
||||
AppLogger.Warn(
|
||||
"DesktopShell",
|
||||
$"Process did not terminate after desktop exit cleanup. Forcing process exit. Source='{source}'; ShutdownIntent='{_shutdownIntent}'.");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Forced process termination scheduler failed. Source='{source}'.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void PerformExitCleanup()
|
||||
{
|
||||
if (_exitCleanupCompleted)
|
||||
@@ -886,6 +1024,22 @@ public partial class App : Application
|
||||
disposableRegistry.Dispose();
|
||||
}
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_transparentOverlayWindow.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_transparentOverlayWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||
DisposeTrayIcon();
|
||||
@@ -927,10 +1081,57 @@ public partial class App : Application
|
||||
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
|
||||
LogBrowserStartupDiagnostics();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
|
||||
ReportStartupProgress(StartupStage.Ready, 100, "就绪");
|
||||
|
||||
// 延迟报告 Ready 直到窗口实际打开并可见
|
||||
// 使用 Opened 事件确保所有资源已加载完毕
|
||||
mainWindow.Opened += OnMainWindowOpened;
|
||||
|
||||
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
||||
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Ready,
|
||||
ProgressPercent = 100,
|
||||
Message = "就绪"
|
||||
});
|
||||
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主窗口打开完成事件 - 此时所有组件、资源及功能模块均已完全加载
|
||||
/// </summary>
|
||||
private void OnMainWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is MainWindow mainWindow)
|
||||
{
|
||||
mainWindow.Opened -= OnMainWindowOpened;
|
||||
|
||||
AppLogger.Info("App", "Main window opened and ready. Reporting Ready to Launcher...");
|
||||
|
||||
// 完成系统初始化加载项
|
||||
_loadingStateManager?.CompleteItem("system.init", "系统初始化完成");
|
||||
|
||||
// 报告 Ready 状态,启动器可以安全关闭 Splash 窗口
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "就绪");
|
||||
|
||||
// 停止加载状态上报
|
||||
_loadingStateReporter?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private MainWindow GetOrCreateMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
@@ -1058,11 +1259,9 @@ public partial class App : Application
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
// 检查三指滑动功能是否启用
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (appSnapshot.EnableThreeFingerSwipe)
|
||||
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
||||
{
|
||||
// 显示透明覆盖层窗口
|
||||
EnsureTransparentOverlayWindow();
|
||||
_transparentOverlayWindow?.Show();
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
|
||||
public string UpdateDownloadSource { get; set; } = "github";
|
||||
public string UpdateDownloadSource { get; set; } = "pdc";
|
||||
|
||||
public int UpdateDownloadThreads { get; set; } = 4;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -32,11 +33,26 @@ public sealed class Program
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
if (activationAcknowledged)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,12 +100,15 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -122,13 +125,16 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -145,11 +151,61 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
|
||||
@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
|
||||
@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否已连接到 Launcher
|
||||
/// </summary>
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// </summary>
|
||||
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
return !string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
|
||||
// 优先检查环境变量
|
||||
if (!string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>)
|
||||
foreach (var arg in Environment.GetCommandLineArgs())
|
||||
{
|
||||
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal file
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System.Collections.Concurrent;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态管理器 - 管理所有加载项的状态
|
||||
/// </summary>
|
||||
public class LoadingStateManager : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, LoadingItem> _items = new();
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _startTimes = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件
|
||||
/// </summary>
|
||||
public event EventHandler<OverallProgressChangedEventArgs>? OverallProgressChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage CurrentStage { get; private set; } = StartupStage.Initializing;
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比
|
||||
/// </summary>
|
||||
public int OverallProgressPercent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否正在加载
|
||||
/// </summary>
|
||||
public bool IsLoading => _items.Values.Any(i => i.State == LoadingState.InProgress);
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
public bool HasErrors => _items.Values.Any(i => i.State == LoadingState.Failed);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有加载项
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<LoadingItem> GetAllItems() => _items.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 获取活动的加载项
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<LoadingItem> GetActiveItems() =>
|
||||
_items.Values.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 注册加载项
|
||||
/// </summary>
|
||||
public LoadingItem RegisterItem(
|
||||
string id,
|
||||
LoadingItemType type,
|
||||
string name,
|
||||
string? description = null,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
{
|
||||
var item = new LoadingItem
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
Name = name,
|
||||
Description = description,
|
||||
State = LoadingState.Pending,
|
||||
ProgressPercent = 0,
|
||||
Metadata = metadata,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = item;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = item,
|
||||
PreviousState = null,
|
||||
CurrentState = item.State
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开始加载
|
||||
/// </summary>
|
||||
public void StartItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes[id] = startTime;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.InProgress,
|
||||
StartTime = startTime,
|
||||
Message = message ?? $"正在加载 {item.Name}...",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度
|
||||
/// </summary>
|
||||
public void UpdateProgress(string id, int percent, string? message = null, int? estimatedRemainingSeconds = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
ProgressPercent = Math.Clamp(percent, 0, 100),
|
||||
Message = message ?? item.Message,
|
||||
EstimatedRemainingSeconds = estimatedRemainingSeconds,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = item.State,
|
||||
CurrentState = updatedItem.State,
|
||||
IsProgressUpdate = true
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成加载
|
||||
/// </summary>
|
||||
public void CompleteItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Completed,
|
||||
ProgressPercent = 100,
|
||||
EndTime = endTime,
|
||||
Message = message ?? $"{item.Name} 加载完成",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记失败
|
||||
/// </summary>
|
||||
public void FailItem(string id, string errorMessage, string? details = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var fullErrorMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
: $"{errorMessage}: {details}";
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Failed,
|
||||
ErrorMessage = fullErrorMessage,
|
||||
EndTime = endTime,
|
||||
Message = $"{item.Name} 加载失败",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记超时
|
||||
/// </summary>
|
||||
public void TimeoutItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Timeout,
|
||||
EndTime = endTime,
|
||||
Message = message ?? $"{item.Name} 加载超时",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置当前启动阶段
|
||||
/// </summary>
|
||||
public void SetStage(StartupStage stage, string? message = null)
|
||||
{
|
||||
CurrentStage = stage;
|
||||
|
||||
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
|
||||
{
|
||||
Stage = stage,
|
||||
OverallProgressPercent = OverallProgressPercent,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新整体进度
|
||||
/// </summary>
|
||||
private void UpdateOverallProgress()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _items.Values.ToList();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
OverallProgressPercent = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算加权进度
|
||||
var totalWeight = items.Count;
|
||||
var completedWeight = items.Count(i => i.State == LoadingState.Completed);
|
||||
var inProgressWeight = items
|
||||
.Where(i => i.State == LoadingState.InProgress)
|
||||
.Sum(i => i.ProgressPercent / 100.0);
|
||||
|
||||
var progress = (int)((completedWeight + inProgressWeight) / totalWeight * 100);
|
||||
OverallProgressPercent = Math.Clamp(progress, 0, 100);
|
||||
|
||||
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
|
||||
{
|
||||
Stage = CurrentStage,
|
||||
OverallProgressPercent = OverallProgressPercent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取加载状态消息
|
||||
/// </summary>
|
||||
public LoadingStateMessage GetLoadingStateMessage()
|
||||
{
|
||||
var items = _items.Values.ToList();
|
||||
var activeItems = items.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
|
||||
var errorItems = items.Where(i => i.State == LoadingState.Failed).ToList();
|
||||
|
||||
return new LoadingStateMessage
|
||||
{
|
||||
Stage = CurrentStage,
|
||||
OverallProgressPercent = OverallProgressPercent,
|
||||
ActiveItems = activeItems,
|
||||
CompletedCount = items.Count(i => i.State == LoadingState.Completed),
|
||||
TotalCount = items.Count,
|
||||
HasErrors = errorItems.Any(),
|
||||
ErrorMessages = errorItems.Select(i => $"{i.Name}: {i.ErrorMessage}").ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有加载项
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_items.Clear();
|
||||
_startTimes.Clear();
|
||||
OverallProgressPercent = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查超时项
|
||||
/// </summary>
|
||||
public void CheckTimeouts(TimeSpan timeout)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeoutItems = _items.Values
|
||||
.Where(i => i.State == LoadingState.InProgress && i.StartTime.HasValue)
|
||||
.Where(i => now - i.StartTime.Value > timeout)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in timeoutItems)
|
||||
{
|
||||
TimeoutItem(item.Id, $"{item.Name} 加载超时(超过 {timeout.TotalSeconds} 秒)");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_items.Clear();
|
||||
_startTimes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态变更事件参数
|
||||
/// </summary>
|
||||
public class LoadingStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public required LoadingItem Item { get; init; }
|
||||
public LoadingState? PreviousState { get; init; }
|
||||
public required LoadingState CurrentState { get; init; }
|
||||
public bool IsProgressUpdate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件参数
|
||||
/// </summary>
|
||||
public class OverallProgressChangedEventArgs : EventArgs
|
||||
{
|
||||
public StartupStage Stage { get; init; }
|
||||
public int OverallProgressPercent { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal file
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal file
@@ -0,0 +1,360 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态上报器 - 将加载状态实时上报给 Launcher
|
||||
/// </summary>
|
||||
public class LoadingStateReporter : IDisposable
|
||||
{
|
||||
private readonly LoadingStateManager _manager;
|
||||
private readonly LauncherIpcClient? _ipcClient;
|
||||
private readonly System.Timers.Timer _reportTimer;
|
||||
private readonly object _lock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// 上报间隔(毫秒)
|
||||
/// </summary>
|
||||
public int ReportIntervalMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用批量上报优化
|
||||
/// </summary>
|
||||
public bool EnableBatching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 最小上报间隔(毫秒),用于限制高频更新
|
||||
/// </summary>
|
||||
public int MinReportIntervalMs { get; set; } = 50;
|
||||
|
||||
private DateTimeOffset _lastReportTime = DateTimeOffset.MinValue;
|
||||
private DetailedProgressMessage? _pendingMessage;
|
||||
private bool _hasPendingMessage;
|
||||
|
||||
public LoadingStateReporter(
|
||||
LoadingStateManager manager,
|
||||
LauncherIpcClient? ipcClient = null)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_ipcClient = ipcClient;
|
||||
|
||||
// 创建定时上报定时器
|
||||
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
||||
_reportTimer.Elapsed += OnReportTimerElapsed;
|
||||
_reportTimer.AutoReset = true;
|
||||
|
||||
// 订阅状态变更事件
|
||||
_manager.StateChanged += OnStateChanged;
|
||||
_manager.OverallProgressChanged += OnOverallProgressChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动上报
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_reportTimer.Start();
|
||||
AppLogger.Info("LoadingStateReporter", "Loading state reporter started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止上报
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_reportTimer.Stop();
|
||||
|
||||
// 发送任何待处理的消息
|
||||
FlushPendingMessage();
|
||||
|
||||
AppLogger.Info("LoadingStateReporter", "Loading state reporter stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 立即上报当前状态
|
||||
/// </summary>
|
||||
public async Task ReportImmediatelyAsync()
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var message = CreateDetailedProgressMessage();
|
||||
await SendMessageAsync(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报单个加载项的进度
|
||||
/// </summary>
|
||||
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item == null) return;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
ProgressPercent = percent,
|
||||
Message = message ?? item.Message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
CurrentItem = updatedItem,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message,
|
||||
IsMajorUpdate = false
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报阶段变更
|
||||
/// </summary>
|
||||
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message ?? $"进入阶段: {stage}",
|
||||
IsMajorUpdate = true
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报错误
|
||||
/// </summary>
|
||||
public async Task ReportErrorAsync(string errorMessage, string? details = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var fullMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
: $"{errorMessage}: {details}";
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = fullMessage,
|
||||
IsMajorUpdate = true
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件处理
|
||||
/// </summary>
|
||||
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
// 重要状态变更立即上报
|
||||
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Timeout)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReportImmediatelyAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to report state change: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其他状态变更标记为待处理
|
||||
QueueMessage(CreateDetailedProgressMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件处理
|
||||
/// </summary>
|
||||
private void OnOverallProgressChanged(object? sender, OverallProgressChangedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
QueueMessage(CreateDetailedProgressMessage(e.Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时上报处理
|
||||
/// </summary>
|
||||
private void OnReportTimerElapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
FlushPendingMessage();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将消息加入待处理队列
|
||||
/// </summary>
|
||||
private void QueueMessage(DetailedProgressMessage message)
|
||||
{
|
||||
if (!EnableBatching)
|
||||
{
|
||||
// 如果不启用批量,立即发送
|
||||
_ = Task.Run(async () => await SendMessageAsync(message));
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_pendingMessage = message;
|
||||
_hasPendingMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新待处理消息
|
||||
/// </summary>
|
||||
private void FlushPendingMessage()
|
||||
{
|
||||
DetailedProgressMessage? message;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_hasPendingMessage) return;
|
||||
|
||||
message = _pendingMessage;
|
||||
_pendingMessage = null;
|
||||
_hasPendingMessage = false;
|
||||
}
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to flush pending message: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建详细的进度消息
|
||||
/// </summary>
|
||||
private DetailedProgressMessage CreateDetailedProgressMessage(string? message = null)
|
||||
{
|
||||
var activeItems = _manager.GetActiveItems().ToList();
|
||||
var currentItem = activeItems.FirstOrDefault();
|
||||
|
||||
return new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
CurrentItem = currentItem,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message ?? currentItem?.Message,
|
||||
IsMajorUpdate = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送消息
|
||||
/// </summary>
|
||||
private async Task SendMessageAsync(DetailedProgressMessage message)
|
||||
{
|
||||
if (_ipcClient == null) return;
|
||||
|
||||
// 检查最小上报间隔
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var elapsed = now - _lastReportTime;
|
||||
if (elapsed.TotalMilliseconds < MinReportIntervalMs)
|
||||
{
|
||||
await Task.Delay(MinReportIntervalMs - (int)elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 转换为 StartupProgressMessage 以保持兼容性
|
||||
var baseMessage = new StartupProgressMessage
|
||||
{
|
||||
Stage = message.Stage,
|
||||
ProgressPercent = message.ProgressPercent,
|
||||
Message = FormatMessage(message),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _ipcClient.ReportProgressAsync(baseMessage);
|
||||
_lastReportTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to send message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化消息
|
||||
/// </summary>
|
||||
private string FormatMessage(DetailedProgressMessage message)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (message.CurrentItem != null)
|
||||
{
|
||||
parts.Add($"[{message.CurrentItem.Type}] {message.CurrentItem.Name}");
|
||||
|
||||
if (message.CurrentItem.ProgressPercent > 0)
|
||||
{
|
||||
parts.Add($"{message.CurrentItem.ProgressPercent}%");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message.Message))
|
||||
{
|
||||
parts.Add(message.Message);
|
||||
}
|
||||
|
||||
var completedCount = message.AllItems?.Count(i => i.State == LoadingState.Completed) ?? 0;
|
||||
var totalCount = message.AllItems?.Count ?? 0;
|
||||
|
||||
if (totalCount > 0)
|
||||
{
|
||||
parts.Add($"({completedCount}/{totalCount})");
|
||||
}
|
||||
|
||||
return string.Join(" - ", parts);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
Stop();
|
||||
|
||||
_reportTimer.Elapsed -= OnReportTimerElapsed;
|
||||
_reportTimer.Dispose();
|
||||
|
||||
_manager.StateChanged -= OnStateChanged;
|
||||
_manager.OverallProgressChanged -= OnOverallProgressChanged;
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal file
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态管理使用示例
|
||||
/// </summary>
|
||||
public static class LoadingStateUsageExample
|
||||
{
|
||||
/// <summary>
|
||||
/// 示例:插件加载
|
||||
/// </summary>
|
||||
public static async Task LoadPluginsExample(LoadingStateManager manager)
|
||||
{
|
||||
// 注册插件加载项
|
||||
var pluginItem = manager.RegisterItem(
|
||||
"plugins.core",
|
||||
LoadingItemType.Plugin,
|
||||
"核心插件",
|
||||
"加载系统核心插件",
|
||||
new Dictionary<string, string> { { "version", "1.0.0" } });
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem("plugins.core", "正在下载插件...");
|
||||
|
||||
try
|
||||
{
|
||||
// 模拟下载进度
|
||||
for (int i = 0; i <= 100; i += 10)
|
||||
{
|
||||
manager.UpdateProgress(
|
||||
"plugins.core",
|
||||
i,
|
||||
$"正在下载... {i}%",
|
||||
estimatedRemainingSeconds: (100 - i) / 10);
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
// 完成加载
|
||||
manager.CompleteItem("plugins.core", "核心插件加载完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 标记失败
|
||||
manager.FailItem("plugins.core", "插件加载失败", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:组件加载
|
||||
/// </summary>
|
||||
public static async Task LoadComponentsExample(LoadingStateManager manager)
|
||||
{
|
||||
var components = new[]
|
||||
{
|
||||
("comp.weather", "天气组件"),
|
||||
("comp.clock", "时钟组件"),
|
||||
("comp.calendar", "日历组件")
|
||||
};
|
||||
|
||||
foreach (var (id, name) in components)
|
||||
{
|
||||
// 注册组件
|
||||
manager.RegisterItem(id, LoadingItemType.Component, name);
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem(id, $"正在加载 {name}...");
|
||||
|
||||
// 模拟加载过程
|
||||
for (int i = 0; i <= 100; i += 20)
|
||||
{
|
||||
manager.UpdateProgress(id, i);
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
// 完成
|
||||
manager.CompleteItem(id, $"{name} 加载完成");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:网络资源加载
|
||||
/// </summary>
|
||||
public static async Task LoadNetworkResourcesExample(LoadingStateManager manager)
|
||||
{
|
||||
// 注册网络加载项
|
||||
manager.RegisterItem(
|
||||
"network.config",
|
||||
LoadingItemType.Network,
|
||||
"配置数据",
|
||||
"从服务器获取最新配置");
|
||||
|
||||
manager.StartItem("network.config", "正在连接服务器...");
|
||||
|
||||
try
|
||||
{
|
||||
// 模拟网络请求
|
||||
await Task.Delay(1000);
|
||||
|
||||
manager.UpdateProgress("network.config", 50, "正在下载数据...");
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
manager.CompleteItem("network.config", "配置数据已更新");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
manager.FailItem("network.config", "网络请求失败", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:带超时的加载
|
||||
/// </summary>
|
||||
public static async Task LoadWithTimeoutExample(
|
||||
LoadingStateManager manager,
|
||||
LoadingTimeoutHandler timeoutHandler)
|
||||
{
|
||||
// 设置超时时间为 10 秒
|
||||
timeoutHandler.SetItemTimeout("data.heavy", TimeSpan.FromSeconds(10));
|
||||
|
||||
// 注册加载项
|
||||
manager.RegisterItem(
|
||||
"data.heavy",
|
||||
LoadingItemType.Data,
|
||||
"大数据处理",
|
||||
"处理大量数据,可能需要较长时间");
|
||||
|
||||
// 订阅超时事件
|
||||
timeoutHandler.ItemTimeout += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"加载项 '{e.ItemName}' 超时!");
|
||||
};
|
||||
|
||||
timeoutHandler.ItemRetry += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"正在重试 '{e.ItemName}' ({e.RetryCount}/{e.MaxRetryCount})...");
|
||||
};
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem("data.heavy", "正在处理数据...");
|
||||
|
||||
// 模拟长时间操作
|
||||
await Task.Delay(15000);
|
||||
|
||||
// 完成
|
||||
manager.CompleteItem("data.heavy", "数据处理完成");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:完整启动流程
|
||||
/// </summary>
|
||||
public static async Task FullStartupExample(
|
||||
LoadingStateManager manager,
|
||||
LoadingStateReporter reporter,
|
||||
LoadingTimeoutHandler timeoutHandler)
|
||||
{
|
||||
// 启动超时处理器
|
||||
timeoutHandler.Start();
|
||||
|
||||
// 设置阶段
|
||||
manager.SetStage(StartupStage.Initializing, "开始初始化...");
|
||||
|
||||
// 1. 系统初始化
|
||||
manager.RegisterItem("system.init", LoadingItemType.System, "系统初始化");
|
||||
manager.StartItem("system.init");
|
||||
await Task.Delay(500);
|
||||
manager.CompleteItem("system.init");
|
||||
|
||||
// 2. 加载设置
|
||||
manager.SetStage(StartupStage.LoadingSettings, "正在加载设置...");
|
||||
manager.RegisterItem("settings.load", LoadingItemType.Settings, "用户设置");
|
||||
manager.StartItem("settings.load");
|
||||
await Task.Delay(800);
|
||||
manager.CompleteItem("settings.load");
|
||||
|
||||
// 3. 加载插件
|
||||
manager.SetStage(StartupStage.LoadingPlugins, "正在加载插件...");
|
||||
await LoadPluginsExample(manager);
|
||||
|
||||
// 4. 加载组件
|
||||
await LoadComponentsExample(manager);
|
||||
|
||||
// 5. 加载网络资源
|
||||
await LoadNetworkResourcesExample(manager);
|
||||
|
||||
// 6. 初始化界面
|
||||
manager.SetStage(StartupStage.InitializingUI, "正在初始化界面...");
|
||||
manager.RegisterItem("ui.init", LoadingItemType.System, "界面初始化");
|
||||
manager.StartItem("ui.init");
|
||||
await Task.Delay(600);
|
||||
manager.CompleteItem("ui.init");
|
||||
|
||||
// 完成
|
||||
manager.SetStage(StartupStage.Ready, "加载完成");
|
||||
|
||||
// 停止超时处理器
|
||||
timeoutHandler.Stop();
|
||||
}
|
||||
}
|
||||
275
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
275
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载超时处理器 - 监控加载项超时并执行相应处理
|
||||
/// </summary>
|
||||
public class LoadingTimeoutHandler : IDisposable
|
||||
{
|
||||
private readonly LoadingStateManager _manager;
|
||||
private readonly System.Timers.Timer _checkTimer;
|
||||
private readonly Dictionary<string, TimeSpan> _itemTimeouts = new();
|
||||
private readonly Dictionary<string, int> _retryCounts = new();
|
||||
private readonly object _lock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// 默认超时时间
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// 最大重试次数
|
||||
/// </summary>
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 检查间隔
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// 超时事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingTimeoutEventArgs>? ItemTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// 重试事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingRetryEventArgs>? ItemRetry;
|
||||
|
||||
/// <summary>
|
||||
/// 最终失败事件(超过最大重试次数)
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingTimeoutEventArgs>? ItemFailed;
|
||||
|
||||
public LoadingTimeoutHandler(LoadingStateManager manager)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
|
||||
_checkTimer = new System.Timers.Timer(CheckInterval.TotalMilliseconds);
|
||||
_checkTimer.Elapsed += OnCheckTimerElapsed;
|
||||
_checkTimer.AutoReset = true;
|
||||
|
||||
// 订阅状态变更事件
|
||||
_manager.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动监控
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_checkTimer.Start();
|
||||
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止监控
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_checkTimer.Stop();
|
||||
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为特定加载项设置超时
|
||||
/// </summary>
|
||||
public void SetItemTimeout(string itemId, TimeSpan timeout)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_itemTimeouts[itemId] = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取加载项的超时时间
|
||||
/// </summary>
|
||||
public TimeSpan GetItemTimeout(string itemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _itemTimeouts.TryGetValue(itemId, out var timeout) ? timeout : DefaultTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置重试计数
|
||||
/// </summary>
|
||||
public void ResetRetryCount(string itemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_retryCounts[itemId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时检查超时
|
||||
/// </summary>
|
||||
private void OnCheckTimerElapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var activeItems = _manager.GetActiveItems().ToList();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var item in activeItems)
|
||||
{
|
||||
if (!item.StartTime.HasValue) continue;
|
||||
|
||||
var timeout = GetItemTimeout(item.Id);
|
||||
var elapsed = now - item.StartTime.Value;
|
||||
|
||||
if (elapsed > timeout)
|
||||
{
|
||||
HandleTimeout(item.Id, elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingTimeoutHandler", $"Error checking timeouts: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理超时
|
||||
/// </summary>
|
||||
private void HandleTimeout(string itemId, TimeSpan elapsed)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var retryCount = _retryCounts.GetValueOrDefault(itemId, 0);
|
||||
|
||||
if (retryCount < MaxRetryCount)
|
||||
{
|
||||
// 重试
|
||||
_retryCounts[itemId] = retryCount + 1;
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item != null)
|
||||
{
|
||||
AppLogger.Warn("LoadingTimeoutHandler",
|
||||
$"Item '{item.Name}' timed out after {elapsed.TotalSeconds}s, retrying ({retryCount + 1}/{MaxRetryCount})...");
|
||||
|
||||
ItemRetry?.Invoke(this, new LoadingRetryEventArgs
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = item.Name,
|
||||
RetryCount = retryCount + 1,
|
||||
MaxRetryCount = MaxRetryCount,
|
||||
ElapsedTime = elapsed
|
||||
});
|
||||
|
||||
// 重新启动该项
|
||||
_manager.StartItem(itemId, $"第 {retryCount + 1} 次重试...");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 最终失败
|
||||
_retryCounts.Remove(itemId);
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item != null)
|
||||
{
|
||||
AppLogger.Error("LoadingTimeoutHandler",
|
||||
$"Item '{item.Name}' failed after {MaxRetryCount} retries ({elapsed.TotalSeconds}s)");
|
||||
|
||||
var args = new LoadingTimeoutEventArgs
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = item.Name,
|
||||
ElapsedTime = elapsed,
|
||||
RetryCount = MaxRetryCount,
|
||||
IsFinalFailure = true
|
||||
};
|
||||
|
||||
ItemTimeout?.Invoke(this, args);
|
||||
ItemFailed?.Invoke(this, args);
|
||||
|
||||
// 标记为失败
|
||||
_manager.FailItem(itemId,
|
||||
$"加载超时(超过 {elapsed.TotalSeconds:F0} 秒)",
|
||||
$"已重试 {MaxRetryCount} 次但仍失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件处理
|
||||
/// </summary>
|
||||
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
|
||||
{
|
||||
// 当项完成或失败时,清除重试计数
|
||||
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Cancelled)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_retryCounts.Remove(e.Item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// 当项开始时,如果是第一次开始,初始化重试计数
|
||||
if (e.CurrentState == LoadingState.InProgress &&
|
||||
(e.PreviousState == null || e.PreviousState == LoadingState.Pending))
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_retryCounts.ContainsKey(e.Item.Id))
|
||||
{
|
||||
_retryCounts[e.Item.Id] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
Stop();
|
||||
|
||||
_checkTimer.Elapsed -= OnCheckTimerElapsed;
|
||||
_checkTimer.Dispose();
|
||||
|
||||
_manager.StateChanged -= OnStateChanged;
|
||||
|
||||
_itemTimeouts.Clear();
|
||||
_retryCounts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载超时事件参数
|
||||
/// </summary>
|
||||
public class LoadingTimeoutEventArgs : EventArgs
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string ItemName { get; init; }
|
||||
public required TimeSpan ElapsedTime { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public bool IsFinalFailure { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载重试事件参数
|
||||
/// </summary>
|
||||
public class LoadingRetryEventArgs : EventArgs
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string ItemName { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public required int MaxRetryCount { get; init; }
|
||||
public required TimeSpan ElapsedTime { get; init; }
|
||||
}
|
||||
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
@@ -0,0 +1,464 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort PDC client that maps PDC responses to the existing update result model.
|
||||
/// This keeps launcher update contracts stable while allowing a gradual migration.
|
||||
/// </summary>
|
||||
public sealed class PdcReleaseUpdateService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public PdcReleaseUpdateService(HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
var endpoint = ResolveEndpoint();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC endpoint is not configured.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
|
||||
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var channelId = ResolveChannelId(metadata, includePrerelease);
|
||||
if (string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
channelId = includePrerelease ? "preview" : "stable";
|
||||
}
|
||||
|
||||
var latestUrl = BuildUri(
|
||||
endpoint,
|
||||
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
||||
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC latest distribution version is invalid.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var distributionId = ReadString(latestNode, "distributionId");
|
||||
if (string.IsNullOrWhiteSpace(distributionId))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC latest distribution id is missing.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var hasUpdate = latestVersion > normalizedCurrentVersion;
|
||||
if (!isForce && !hasUpdate)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: false);
|
||||
}
|
||||
|
||||
var subChannel = ResolveSubChannel();
|
||||
var distributionUrl = BuildUri(
|
||||
endpoint,
|
||||
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
|
||||
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var assets = ResolveAssets(distributionNode);
|
||||
if (assets.Count == 0)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var release = new GitHubReleaseInfo(
|
||||
TagName: $"v{latestVersionText}",
|
||||
Name: $"PDC Distribution {latestVersionText}",
|
||||
IsPrerelease: includePrerelease,
|
||||
IsDraft: false,
|
||||
PublishedAt: DateTimeOffset.UtcNow,
|
||||
Assets: assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PDC request failed: {ex.Message}",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = ResolveToken();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.Clone();
|
||||
}
|
||||
|
||||
return root.Clone();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
|
||||
{
|
||||
var assets = new List<GitHubReleaseAsset>();
|
||||
if (distributionNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return assets;
|
||||
}
|
||||
|
||||
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
|
||||
assetsNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var assetNode in assetsNode.EnumerateArray())
|
||||
{
|
||||
if (assetNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(assetNode, "name");
|
||||
var url = ReadString(assetNode, "url") ??
|
||||
ReadString(assetNode, "downloadUrl") ??
|
||||
ReadString(assetNode, "browserDownloadUrl");
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var size = ReadInt64(assetNode, "size") ?? 0L;
|
||||
var sha256 = ReadString(assetNode, "sha256");
|
||||
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.Count > 0)
|
||||
{
|
||||
return assets;
|
||||
}
|
||||
|
||||
// Field-level fallback for service-side URL projection.
|
||||
var manifestUrl = ReadString(distributionNode, "manifestUrl")
|
||||
?? ReadString(distributionNode, "fileMapUrl");
|
||||
var signatureUrl = ReadString(distributionNode, "signatureUrl")
|
||||
?? ReadString(distributionNode, "fileMapSignatureUrl");
|
||||
var archiveUrl = ReadString(distributionNode, "archiveUrl")
|
||||
?? ReadString(distributionNode, "updateArchiveUrl")
|
||||
?? ReadString(distributionNode, "payloadUrl");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifestUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signatureUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(archiveUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
|
||||
{
|
||||
if (metadataNode.ValueKind != JsonValueKind.Object ||
|
||||
!metadataNode.TryGetProperty("channels", out var channelsNode))
|
||||
{
|
||||
return includePrerelease ? "preview" : "stable";
|
||||
}
|
||||
|
||||
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
|
||||
if (channelsNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return defaultChannelId;
|
||||
}
|
||||
|
||||
string? matchedPreview = null;
|
||||
string? matchedStable = null;
|
||||
|
||||
foreach (var channel in channelsNode.EnumerateObject())
|
||||
{
|
||||
var name = ReadString(channel.Value, "name") ?? channel.Name;
|
||||
if (string.IsNullOrWhiteSpace(matchedPreview) &&
|
||||
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
matchedPreview = channel.Name;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(matchedStable) &&
|
||||
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
matchedStable = channel.Name;
|
||||
}
|
||||
}
|
||||
|
||||
if (includePrerelease)
|
||||
{
|
||||
return matchedPreview ?? defaultChannelId ?? "preview";
|
||||
}
|
||||
|
||||
return matchedStable ?? defaultChannelId ?? "stable";
|
||||
}
|
||||
|
||||
private static string ResolveSubChannel()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
|
||||
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}_{arch}_release_folderClassic";
|
||||
}
|
||||
|
||||
private static string? ResolveEndpoint()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
|
||||
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string? ResolveToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
|
||||
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
|
||||
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
|
||||
}
|
||||
|
||||
private static string BuildUri(string endpoint, string relativePath)
|
||||
{
|
||||
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement node, string propertyName)
|
||||
{
|
||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: value.ToString();
|
||||
}
|
||||
|
||||
private static long? ReadInt64(JsonElement node, string propertyName)
|
||||
{
|
||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.TryGetInt64(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().TrimStart('v', 'V');
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(normalized, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = NormalizeVersion(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version NormalizeVersion(Version version)
|
||||
{
|
||||
var major = Math.Max(0, version.Major);
|
||||
var minor = Math.Max(0, version.Minor);
|
||||
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
||||
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
||||
return revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
}
|
||||
|
||||
private static string FormatVersionText(Version version)
|
||||
{
|
||||
return version.Revision > 0
|
||||
? version.ToString(4)
|
||||
: version.ToString(3);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
||||
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.DownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.RedownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.RedownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_releaseUpdateService.Dispose();
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_pdcReleaseUpdateService.Dispose();
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pdcResult = isForce
|
||||
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (pdcResult.Success)
|
||||
{
|
||||
return pdcResult;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
private const byte ActivationRequestCode = 0x41; // 'A'
|
||||
private const byte ActivationAckCode = 0x4B; // 'K'
|
||||
private const byte ActivationNackCode = 0x4E; // 'N'
|
||||
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
{
|
||||
return TryNotifyPrimaryInstance(timeout, out _);
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
|
||||
{
|
||||
if (_ownsMutex || _disposed)
|
||||
{
|
||||
failureReason = _ownsMutex
|
||||
? "current_instance_is_primary"
|
||||
: "single_instance_service_disposed";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: _pipeName,
|
||||
direction: PipeDirection.Out,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||
client.WriteByte(1);
|
||||
client.WriteByte(ActivationRequestCode);
|
||||
client.Flush();
|
||||
|
||||
var ack = client.ReadByte();
|
||||
var acknowledged = ack == ActivationAckCode;
|
||||
if (!acknowledged)
|
||||
{
|
||||
failureReason = ack switch
|
||||
{
|
||||
ActivationNackCode => "primary_rejected_activation",
|
||||
-1 => "ack_not_received",
|
||||
_ => $"unexpected_ack_code_{ack}"
|
||||
};
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
failureReason = null;
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureReason = "primary_activation_handshake_exception";
|
||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||
return false;
|
||||
}
|
||||
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.In,
|
||||
PipeDirection.InOut,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
||||
onActivationRequested();
|
||||
var buffer = new byte[1];
|
||||
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
|
||||
var ackCode = ActivationAckCode;
|
||||
|
||||
if (!isActivationRequest)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
onActivationRequested();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var ackBuffer = new[] { ackCode };
|
||||
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
|
||||
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class UpdateSettingsValues
|
||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||
public const string ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
public const string DownloadSourcePdc = "pdc";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
|
||||
|
||||
public static string NormalizeDownloadSource(string? value)
|
||||
{
|
||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? DownloadSourceGhProxy
|
||||
: DownloadSourceGitHub;
|
||||
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourcePdc;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGhProxy;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
|
||||
return DownloadSourcePdc;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
@@ -52,9 +53,9 @@ public sealed class UpdateWorkflowService
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string DeltaManifestFileName = "files.json";
|
||||
private const string DeltaSignatureFileName = "files.json.sig";
|
||||
private const string DeltaArchiveFileName = "update.zip";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignedFileMapSignatureName = "files.json.sig";
|
||||
private const string UpdateArchiveName = "update.zip";
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -81,7 +82,7 @@ public sealed class UpdateWorkflowService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
|
||||
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
|
||||
/// </summary>
|
||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||
{
|
||||
@@ -90,15 +91,11 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return assetNames.Contains(DeltaManifestFileName)
|
||||
&& assetNames.Contains(DeltaSignatureFileName)
|
||||
&& assetNames.Contains(DeltaArchiveFileName);
|
||||
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
|
||||
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
|
||||
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
||||
/// </summary>
|
||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
@@ -112,9 +109,9 @@ public sealed class UpdateWorkflowService
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
if (!IsDeltaUpdateAvailable(checkResult.Release))
|
||||
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain delta update assets.");
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
@@ -132,32 +129,19 @@ public sealed class UpdateWorkflowService
|
||||
var downloadSource = state.UpdateDownloadSource;
|
||||
var downloadThreads = state.UpdateDownloadThreads;
|
||||
|
||||
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
|
||||
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||
{
|
||||
[DeltaManifestFileName] = null!,
|
||||
[DeltaSignatureFileName] = null!,
|
||||
[DeltaArchiveFileName] = null!
|
||||
(manifestAsset, SignedFileMapName),
|
||||
(signatureAsset, SignedFileMapSignatureName),
|
||||
(archiveAsset, UpdateArchiveName)
|
||||
};
|
||||
|
||||
foreach (var asset in checkResult.Release.Assets)
|
||||
{
|
||||
if (requiredAssets.ContainsKey(asset.Name))
|
||||
{
|
||||
requiredAssets[asset.Name] = asset;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredAssets.Any(kvp => kvp.Value is null))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
|
||||
}
|
||||
|
||||
var totalAssets = requiredAssets.Count;
|
||||
var completedAssets = 0;
|
||||
|
||||
foreach (var (name, asset) in requiredAssets)
|
||||
foreach (var (asset, destinationFileName) in requiredAssets)
|
||||
{
|
||||
var destinationPath = Path.Combine(incomingDir, name);
|
||||
var destinationPath = Path.Combine(incomingDir, destinationFileName);
|
||||
|
||||
// Skip if already downloaded and file exists
|
||||
if (File.Exists(destinationPath))
|
||||
@@ -165,7 +149,7 @@ public sealed class UpdateWorkflowService
|
||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", $"Delta asset {name} already downloaded with matching hash, skipping.");
|
||||
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
continue;
|
||||
@@ -189,21 +173,21 @@ public sealed class UpdateWorkflowService
|
||||
if (!result.Success)
|
||||
{
|
||||
// Clean up partially downloaded files
|
||||
foreach (var file in requiredAssets.Keys)
|
||||
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
|
||||
{
|
||||
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
|
||||
}
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}");
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
}
|
||||
|
||||
// Save state indicating a delta update is pending
|
||||
// Save state indicating a signed file-map update is pending.
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
@@ -212,13 +196,13 @@ public sealed class UpdateWorkflowService
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null);
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer.
|
||||
/// Checks whether the pending update is managed by Launcher incoming payload.
|
||||
/// </summary>
|
||||
public bool IsPendingDeltaUpdate()
|
||||
{
|
||||
@@ -229,11 +213,71 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delta updates are identified by the manifest file path
|
||||
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
|
||||
// Incoming payload updates are identified by files.json or incoming directory path.
|
||||
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryResolveDeltaAssets(
|
||||
IReadOnlyList<GitHubReleaseAsset> assets,
|
||||
out GitHubReleaseAsset manifestAsset,
|
||||
out GitHubReleaseAsset signatureAsset,
|
||||
out GitHubReleaseAsset archiveAsset)
|
||||
{
|
||||
manifestAsset = default!;
|
||||
signatureAsset = default!;
|
||||
archiveAsset = default!;
|
||||
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var platformManifest = $"files-{platformSuffix}.json";
|
||||
var platformSignature = $"files-{platformSuffix}.json.sig";
|
||||
var platformArchive = $"update-{platformSuffix}.zip";
|
||||
|
||||
var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
|
||||
var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
|
||||
var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
|
||||
if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
manifestAsset = manifestCandidate;
|
||||
signatureAsset = signatureCandidate;
|
||||
archiveAsset = archiveCandidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string name)
|
||||
{
|
||||
return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
public UpdatePendingInfo? GetPendingUpdate()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
|
||||
@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _previewChannelText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pdcSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gitHubSourceText = string.Empty;
|
||||
|
||||
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
public bool IsPreviewChannelSelected =>
|
||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPdcSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGitHubSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPdcSource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectGitHubSource()
|
||||
{
|
||||
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||
: L("settings.update.status_checking", "Checking update source...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
@@ -2112,6 +2124,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
||||
{
|
||||
UpdateSettingsValues.DownloadSourcePdc => L(
|
||||
"settings.update.source_pdc_desc",
|
||||
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
|
||||
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||
"settings.update.source_ghproxy_desc",
|
||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||
];
|
||||
|
||||
@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
|
||||
ComponentContainer.Child = componentContent;
|
||||
}
|
||||
|
||||
public void UpdateComponentLayout(double width, double height)
|
||||
{
|
||||
ComponentContainer.Width = width;
|
||||
ComponentContainer.Height = height;
|
||||
|
||||
if (ComponentContainer.Child is Control child)
|
||||
{
|
||||
child.Width = width;
|
||||
child.Height = height;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
|
||||
public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
|
||||
// 滑动状态
|
||||
private bool _isSwipeActive;
|
||||
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
|
||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||
_settingsFacade = facade;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
public void SaveLayoutAndHide()
|
||||
{
|
||||
SaveLayout();
|
||||
_regionPassthroughService.ClearInteractiveRegions(this);
|
||||
Hide();
|
||||
|
||||
// Remove all components so that next time we open it builds fresh from snapshot
|
||||
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
|
||||
RenderAllComponents();
|
||||
|
||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_bottomMostService.SendToBottom(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
|
||||
/// </summary>
|
||||
private void UpdateInteractiveRegions()
|
||||
{
|
||||
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
|
||||
_interactiveRegions.Clear();
|
||||
|
||||
foreach (var host in _componentHosts.Values)
|
||||
{
|
||||
var left = Canvas.GetLeft(host);
|
||||
var top = Canvas.GetTop(host);
|
||||
var width = host.Width > 0 ? host.Width : host.Bounds.Width;
|
||||
var height = host.Height > 0 ? host.Height : host.Bounds.Height;
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。
|
||||
_interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24));
|
||||
}
|
||||
|
||||
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<!-- Windows 10/11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
@@ -194,3 +194,9 @@ This repository is organized around a desktop host app plus a host-side plugin e
|
||||
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
|
||||
## VeloPack Integration Note
|
||||
|
||||
- Incremental package build/publish has moved to VeloPack native assets (
|
||||
eleases.win.json + *.nupkg).
|
||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
||||
|
||||
@@ -166,3 +166,10 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
|
||||
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
|
||||
|
||||
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
## VeloPack Release Assets
|
||||
|
||||
- Windows incremental release packaging now uses VeloPack native outputs (
|
||||
eleases.win.json, *.nupkg).
|
||||
- Launcher still performs update apply/rollback; VeloPack is used for package generation.
|
||||
- Legacy delta script flow is retained behind a disabled fallback switch in CI.
|
||||
|
||||
@@ -442,3 +442,10 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
- [Launcher 架构文档](LAUNCHER.md)
|
||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||
- [故障排除指南](TROUBLESHOOTING.md)
|
||||
|
||||
## VeloPack Packaging (Current)
|
||||
|
||||
- Release pipeline now produces VeloPack native assets (
|
||||
eleases.win.json, *.nupkg, RELEASES).
|
||||
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
|
||||
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.
|
||||
|
||||
29
phainon.yml
Normal file
29
phainon.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Phainon Distribution Center (PDC) publish configuration
|
||||
# This file is intentionally conservative: Launcher remains installer/rollback authority.
|
||||
name: "LanMountainDesktop"
|
||||
|
||||
components:
|
||||
app:
|
||||
allowDiffUpdate: true
|
||||
root: "app-$(version)/"
|
||||
includes:
|
||||
- "**"
|
||||
launcher:
|
||||
root: ""
|
||||
includes:
|
||||
- "**"
|
||||
excludes:
|
||||
- "app-*/**"
|
||||
- ".launcher/update/incoming/**"
|
||||
- "files.json"
|
||||
- "files.json.sig"
|
||||
- "update.zip"
|
||||
|
||||
variables:
|
||||
number: 0
|
||||
|
||||
# Replace these roots in CI/CD or environment-specific templates when enabling PDCC publish.
|
||||
fileRepoRoot: "https://example.invalid/lanmountain/distribution-v1/repo/"
|
||||
archiveRoot: "https://example.invalid/lanmountain/distribution-v1/$(primaryVersion)/$(version)/"
|
||||
bucketKeyRoot: "lanmountain/distribution-v1/repo/"
|
||||
archiveBucketKeyRoot: "lanmountain/distribution-v1/$(primaryVersion)/$(version)/"
|
||||
@@ -1,95 +1,165 @@
|
||||
# Generate-DeltaPackage.ps1
|
||||
# 生成增量更新包 (delta.zip + files.json)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
||||
Write-Host "从版本: $PreviousVersion"
|
||||
Write-Host "到版本: $CurrentVersion"
|
||||
Write-Host "上一版本目录: $PreviousDir"
|
||||
Write-Host "当前版本目录: $CurrentDir"
|
||||
Write-Host "输出目录: $OutputDir"
|
||||
Write-Host ""
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
# 确保输出目录存在
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
function Get-NormalizedRelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir,
|
||||
|
||||
# 计算文件 SHA256
|
||||
function Get-FileSha256 {
|
||||
param([string]$Path)
|
||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
||||
return $hash.Hash.ToLower()
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FullPath
|
||||
)
|
||||
|
||||
$separator = [System.IO.Path]::DirectorySeparatorChar
|
||||
$altSeparator = [System.IO.Path]::AltDirectorySeparatorChar
|
||||
|
||||
$root = [System.IO.Path]::GetFullPath($RootDir).Replace($altSeparator, $separator).TrimEnd($separator)
|
||||
$path = [System.IO.Path]::GetFullPath($FullPath).Replace($altSeparator, $separator)
|
||||
|
||||
$comparison = if ($separator -eq '\') {
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
}
|
||||
else {
|
||||
[System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
$rootWithSeparator = "$root$separator"
|
||||
if ($path.StartsWith($rootWithSeparator, $comparison)) {
|
||||
$relative = $path.Substring($rootWithSeparator.Length)
|
||||
}
|
||||
elseif ($path.Equals($root, $comparison)) {
|
||||
$relative = ""
|
||||
}
|
||||
else {
|
||||
throw "File path '$path' is not under root '$root'."
|
||||
}
|
||||
|
||||
return $relative.Replace('\', '/')
|
||||
}
|
||||
|
||||
function Get-FileSha256Hex {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
# 获取目录中所有文件的相对路径和哈希
|
||||
function Get-FileManifest {
|
||||
param([string]$RootDir)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RootDir)) {
|
||||
throw "Directory does not exist: $RootDir"
|
||||
}
|
||||
|
||||
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
|
||||
$manifest = @{}
|
||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
||||
|
||||
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
$manifest[$relativePath] = @{
|
||||
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
|
||||
$manifest[$relativePath] = [ordered]@{
|
||||
Path = $relativePath
|
||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
||||
Size = $file.Length
|
||||
Sha256 = Get-FileSha256Hex -Path $file.FullName
|
||||
Size = [long]$file.Length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $manifest
|
||||
}
|
||||
|
||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
||||
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||
function New-DeltaArchive {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ZipPath,
|
||||
|
||||
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentRoot,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyCollection()]
|
||||
[object[]]$ChangedFiles = @()
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $ZipPath) {
|
||||
Remove-Item -LiteralPath $ZipPath -Force
|
||||
}
|
||||
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($file in $ChangedFiles) {
|
||||
$sourcePath = Join-Path $CurrentRoot $file.Path
|
||||
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||
throw "Changed file was not found while building archive: $sourcePath"
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$zip,
|
||||
$sourcePath,
|
||||
$file.Path,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal
|
||||
) | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Generating incremental package..."
|
||||
Write-Host "From: $PreviousVersion"
|
||||
Write-Host "To: $CurrentVersion"
|
||||
Write-Host "Prev: $PreviousDir"
|
||||
Write-Host "Curr: $CurrentDir"
|
||||
Write-Host "Out: $OutputDir"
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||
|
||||
# 分析文件变更
|
||||
$changedFiles = @()
|
||||
$reusedFiles = @()
|
||||
$deletedFiles = @()
|
||||
|
||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
||||
|
||||
# 检查新增和修改的文件
|
||||
foreach ($path in $currentManifest.Keys) {
|
||||
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
|
||||
$currentFile = $currentManifest[$path]
|
||||
|
||||
|
||||
if ($previousManifest.ContainsKey($path)) {
|
||||
$previousFile = $previousManifest[$path]
|
||||
|
||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||
# 文件未变更,可以复用
|
||||
$reusedFiles += @{
|
||||
$reusedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "reuse"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
}
|
||||
} else {
|
||||
# 文件已修改
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "replace"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -97,9 +167,9 @@ foreach ($path in $currentManifest.Keys) {
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# 新增文件
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "add"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -109,76 +179,51 @@ foreach ($path in $currentManifest.Keys) {
|
||||
}
|
||||
}
|
||||
|
||||
# 检查删除的文件
|
||||
foreach ($path in $previousManifest.Keys) {
|
||||
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
|
||||
if (-not $currentManifest.ContainsKey($path)) {
|
||||
$deletedFiles += @{
|
||||
$deletedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "变更统计:" -ForegroundColor Green
|
||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
||||
Write-Host ""
|
||||
Write-Host "Changed: $($changedFiles.Count)"
|
||||
Write-Host "Reused: $($reusedFiles.Count)"
|
||||
Write-Host "Deleted: $($deletedFiles.Count)"
|
||||
|
||||
# 创建临时目录用于打包
|
||||
$tempDir = Join-Path $OutputDir "temp_delta"
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
||||
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
|
||||
$updateZipPath = Join-Path $OutputDir "update.zip"
|
||||
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
|
||||
|
||||
# 复制变更的文件到临时目录
|
||||
Write-Host "复制变更文件..." -ForegroundColor Yellow
|
||||
foreach ($file in $changedFiles) {
|
||||
$sourcePath = Join-Path $CurrentDir $file.Path
|
||||
$destPath = Join-Path $tempDir $file.Path
|
||||
$destDir = Split-Path -Parent $destPath
|
||||
|
||||
if (-not (Test-Path $destDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourcePath -Destination $destPath -Force
|
||||
}
|
||||
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
|
||||
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
|
||||
|
||||
# 创建 delta.zip
|
||||
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
|
||||
Write-Host "创建增量包: $deltaZipPath" -ForegroundColor Yellow
|
||||
|
||||
if (Test-Path $deltaZipPath) {
|
||||
Remove-Item -Path $deltaZipPath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $deltaZipPath -CompressionLevel Optimal
|
||||
|
||||
# 清理临时目录
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
# 生成 files.json
|
||||
$filesJson = @{
|
||||
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
$filesJson = [ordered]@{
|
||||
FromVersion = $PreviousVersion
|
||||
ToVersion = $CurrentVersion
|
||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
|
||||
Files = $allEntries
|
||||
}
|
||||
|
||||
$filesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
||||
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
|
||||
$jsonText = $filesJson | ConvertTo-Json -Depth 10
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
$filesJsonPath = Join-Path $OutputDir "files.json"
|
||||
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
|
||||
|
||||
# 计算增量包大小
|
||||
$deltaSize = (Get-Item $deltaZipPath).Length
|
||||
$deltaSizeMB = [math]::Round($deltaSize / 1MB, 2)
|
||||
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
|
||||
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||
|
||||
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
|
||||
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "增量包大小: $deltaSizeMB MB"
|
||||
Write-Host "输出文件:"
|
||||
Write-Host " - $deltaZipPath"
|
||||
Write-Host " - $filesJsonPath"
|
||||
Write-Host "Done."
|
||||
Write-Host "update.zip size: $updateSizeMb MB"
|
||||
Write-Host "Generated:"
|
||||
Write-Host " $updateZipPath"
|
||||
Write-Host " $filesJsonPath"
|
||||
Write-Host " $deltaZipPath"
|
||||
Write-Host " $versionedFilesJsonPath"
|
||||
|
||||
@@ -1,65 +1,56 @@
|
||||
# Sign-FileMap.ps1
|
||||
# 对 files.json 进行 RSA 签名
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilesJsonPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PrivateKeyPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
|
||||
Write-Host "文件清单: $FilesJsonPath"
|
||||
Write-Host "私钥: $PrivateKeyPath"
|
||||
Write-Host ""
|
||||
|
||||
# 检查文件是否存在
|
||||
if (-not (Test-Path $FilesJsonPath)) {
|
||||
Write-Error "文件清单不存在: $FilesJsonPath"
|
||||
exit 1
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PrivateKeyPath)) {
|
||||
Write-Error "私钥文件不存在: $PrivateKeyPath"
|
||||
exit 1
|
||||
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
|
||||
throw "Manifest file not found: $FilesJsonPath"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
|
||||
throw "Private key file not found: $PrivateKeyPath"
|
||||
}
|
||||
|
||||
# 确定输出路径
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = "$FilesJsonPath.sig"
|
||||
}
|
||||
|
||||
# 读取文件内容
|
||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
||||
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
|
||||
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
|
||||
|
||||
# 读取私钥
|
||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
||||
|
||||
# 使用 .NET 进行 RSA 签名
|
||||
Add-Type -AssemblyName System.Security.Cryptography
|
||||
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
throw "Private key PEM is empty: $PrivateKeyPath"
|
||||
}
|
||||
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
try {
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$signatureBytes = $rsa.SignData(
|
||||
$manifestBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
}
|
||||
finally {
|
||||
$rsa.Dispose()
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
$signature = $rsa.SignData(
|
||||
$jsonBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
||||
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
|
||||
|
||||
# 转换为 Base64
|
||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
|
||||
# 写入签名文件
|
||||
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
|
||||
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "签名文件: $OutputPath"
|
||||
Write-Host "签名长度: $($signature.Length) 字节"
|
||||
Write-Host "Signed manifest file."
|
||||
Write-Host "Manifest: $FilesJsonPath"
|
||||
Write-Host "Signature: $OutputPath"
|
||||
|
||||
Reference in New Issue
Block a user