mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 17:24:27 +08:00
Compare commits
13 Commits
4f9feafbbe
...
v0.8.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e7d96fe7 | ||
|
|
c5ef418bd9 | ||
|
|
1e6b61db85 | ||
|
|
48ce93b68e | ||
|
|
cddebbcf5a | ||
|
|
24b361b5b9 | ||
|
|
833c69305b | ||
|
|
858612fa8e | ||
|
|
f6a6f97e0b | ||
|
|
02547eeea6 | ||
|
|
8e39ea864f | ||
|
|
6343164b24 | ||
|
|
8e21364eed |
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -67,9 +67,14 @@ jobs:
|
|||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6 \
|
libxi6 libxcursor1 libxext6 \
|
||||||
libxrender1 libxkbcommon-x11-0 \
|
libxrender1 libxkbcommon-x11-0 \
|
||||||
clang zlib1g-dev \
|
clang zlib1g-dev
|
||||||
libportaudio2 libasound2 \
|
|
||||||
libwebkit2gtk-4.1-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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
|
|||||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
|
|||||||
363
.github/workflows/release.yml
vendored
363
.github/workflows/release.yml
vendored
@@ -109,7 +109,7 @@ jobs:
|
|||||||
|
|
||||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||||
|
|
||||||
# AOT 单文件发布
|
# AOT publish
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$launcherPublishDir `
|
-o ./$launcherPublishDir `
|
||||||
@@ -127,7 +127,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 显示发布结果
|
# 鏄剧ず鍙戝竷缁撴灉
|
||||||
Write-Host "Launcher published to: $launcherPublishDir"
|
Write-Host "Launcher published to: $launcherPublishDir"
|
||||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||||
if ($exeFile) {
|
if ($exeFile) {
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 清理不必要的文件(AOT 单文件应该只有一个 exe)
|
# Warn if unexpected extra files are produced
|
||||||
$files = Get-ChildItem -Path $launcherPublishDir -File
|
$files = Get-ChildItem -Path $launcherPublishDir -File
|
||||||
if ($files.Count -gt 1) {
|
if ($files.Count -gt 1) {
|
||||||
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
Write-Host "Warning: Expected single file but found $($files.Count) files"
|
||||||
@@ -317,176 +317,92 @@ jobs:
|
|||||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Create App Package
|
- name: Build Signed FileMap Update Package
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
if: matrix.self_contained == true
|
||||||
run: |
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$platform = "windows-$arch"
|
||||||
$publishDir = "publish/windows-$arch"
|
$publishDir = "publish/windows-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
$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
|
if (-not (Test-Path $currentAppPath)) {
|
||||||
|
Write-Error "Expected app directory not found: $currentAppPath"
|
||||||
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
|
|
||||||
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
|
|
||||||
Write-Host "Creating app-$version-win-$arch.zip..."
|
|
||||||
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
|
|
||||||
|
|
||||||
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
|
|
||||||
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Generate Delta Package
|
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
|
||||||
run: |
|
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
|
||||||
$arch = "${{ matrix.arch }}"
|
|
||||||
$publishDir = "publish/windows-$arch"
|
|
||||||
$appDir = "app-$version"
|
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
|
||||||
$outputDir = "delta-output"
|
|
||||||
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
|
||||||
|
|
||||||
# --- Determine previous version and download its app package 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"
|
|
||||||
|
|
||||||
# 下载旧版本的 app-{version}-win-{arch}.zip
|
|
||||||
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
|
|
||||||
if ($prevAppZip) {
|
|
||||||
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
|
|
||||||
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
|
|
||||||
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
|
|
||||||
|
|
||||||
# 解压 app-{version}.zip
|
|
||||||
$previousAppPath = Join-Path $outputDir "prev-app"
|
|
||||||
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
|
|
||||||
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
|
|
||||||
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
|
||||||
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
|
|
||||||
Write-Host "Extracted $prevFileCount files from previous version for diff"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
|
|
||||||
Write-Host "This is expected for the first release after this fix."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Host "Could not fetch previous release: $_"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Generate delta package using the script ---
|
|
||||||
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
|
|
||||||
Write-Host "Generating delta package from $previousVersion to $version..."
|
|
||||||
& $scriptPath `
|
|
||||||
-PreviousVersion $previousVersion `
|
|
||||||
-CurrentVersion $version `
|
|
||||||
-PreviousDir $previousAppPath `
|
|
||||||
-CurrentDir $currentAppPath `
|
|
||||||
-OutputDir $outputDir
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Generate-DeltaPackage.ps1 failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Host "No previous version available - generating full package..."
|
|
||||||
# Generate a "full" delta package (all files as "add")
|
|
||||||
& $scriptPath `
|
|
||||||
-PreviousVersion "0.0.0" `
|
|
||||||
-CurrentVersion $version `
|
|
||||||
-PreviousDir $currentAppPath `
|
|
||||||
-CurrentDir $currentAppPath `
|
|
||||||
-OutputDir $outputDir
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Generate-DeltaPackage.ps1 failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clean up previous version extraction
|
|
||||||
if ($previousAppPath -and (Test-Path $previousAppPath)) {
|
|
||||||
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Display results
|
|
||||||
$updateZipPath = Join-Path $outputDir "update.zip"
|
|
||||||
if (Test-Path $updateZipPath) {
|
|
||||||
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
|
|
||||||
Write-Host "Created update.zip: $sizeMB MB"
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
exit 1
|
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)) {
|
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||||
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
|
$privateKeyPem = @'
|
||||||
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
|
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||||
exit 0
|
'@.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"
|
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
||||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
|
$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 @"
|
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
||||||
using System;
|
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
||||||
using System.IO;
|
|
||||||
using System.Security.Cryptography;
|
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
||||||
public class RsaSigner {
|
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||||
public static void Sign(string jsonPath, string keyPath, string sigPath) {
|
$rsa.ImportFromPem($privateKeyPem)
|
||||||
var jsonBytes = File.ReadAllBytes(jsonPath);
|
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
||||||
var rsa = RSA.Create();
|
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
||||||
rsa.ImportFromPem(File.ReadAllText(keyPath));
|
|
||||||
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
||||||
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
|
$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)
|
& $signScript `
|
||||||
Remove-Item -Path $privateKeyPath -Force
|
-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
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload Delta Package
|
- name: Upload Signed FileMap Update Package
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
if: matrix.self_contained == true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-delta-windows-x64
|
name: release-update-windows-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
delta-output/files.json
|
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
|
||||||
delta-output/files.json.sig
|
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
|
||||||
delta-output/update.zip
|
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
|
||||||
delta-output/app-*.zip
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
|
|
||||||
- name: Upload Installer
|
- name: Upload Installer
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -516,9 +432,14 @@ jobs:
|
|||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6 \
|
libxi6 libxcursor1 libxext6 \
|
||||||
libxrender1 libxkbcommon-x11-0 \
|
libxrender1 libxkbcommon-x11-0 \
|
||||||
clang zlib1g-dev \
|
clang zlib1g-dev
|
||||||
libportaudio2 libasound2 \
|
|
||||||
libwebkit2gtk-4.1-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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -687,6 +608,90 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -889,10 +894,8 @@ jobs:
|
|||||||
mkdir -p release-files
|
mkdir -p release-files
|
||||||
# Copy installers and packages
|
# Copy installers and packages
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
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)
|
# 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/ \;
|
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
||||||
# Copy app package for future delta generation (app-{version}-win-{arch}.zip)
|
|
||||||
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Files ready for release:"
|
echo "Files ready for release:"
|
||||||
ls -lh release-files/ || echo "No files found in release-files"
|
ls -lh release-files/ || echo "No files found in release-files"
|
||||||
@@ -905,6 +908,44 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Create Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -926,12 +967,12 @@ jobs:
|
|||||||
|
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
Installation: Double-click the .exe file and follow the wizard.
|
||||||
|
|
||||||
### Incremental Update (Windows x64)
|
### Incremental Update Assets
|
||||||
- **files.json** - Update manifest listing changed files
|
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
|
||||||
- **files.json.sig** - RSA signature of the manifest
|
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
|
||||||
- **update.zip** - Archive containing changed files
|
- **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
|
### Linux
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -512,3 +512,5 @@ nul
|
|||||||
/*.deb
|
/*.deb
|
||||||
/*.dmg
|
/*.dmg
|
||||||
/*.AppImage
|
/*.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).
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
-----BEGIN RSA PUBLIC KEY-----
|
-----BEGIN RSA PUBLIC KEY-----
|
||||||
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
|
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||||
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
|
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||||
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
|
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||||
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
|
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||||
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
|
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||||
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
|
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||||
-----END RSA PUBLIC KEY-----
|
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||||
|
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||||
|
FQiGowgqx0l5AgMBAAE=
|
||||||
|
-----END RSA PUBLIC KEY-----
|
||||||
|
|||||||
@@ -91,11 +91,7 @@ internal static class Commands
|
|||||||
"check" => updateEngine.CheckPendingUpdate(),
|
"check" => updateEngine.CheckPendingUpdate(),
|
||||||
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
||||||
"rollback" => updateEngine.RollbackLatest(),
|
"rollback" => updateEngine.RollbackLatest(),
|
||||||
"download" => await updateEngine.DownloadAsync(
|
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
||||||
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),
|
|
||||||
_ => new LauncherResult
|
_ => new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
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(
|
private static LauncherResult ExecutePluginCommand(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
|
|||||||
@@ -184,13 +184,23 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
var completedTask = await readyOrTimeoutOrExit;
|
var completedTask = await readyOrTimeoutOrExit;
|
||||||
|
|
||||||
// 检查是否是进程先退出(异常情况)
|
// Host process exited before reporting Ready.
|
||||||
if (completedTask == processExitTask)
|
if (completedTask == processExitTask)
|
||||||
{
|
{
|
||||||
var exitCode = hostProcess.ExitCode;
|
var exitCode = hostProcess.ExitCode;
|
||||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
|
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||||
|
|
||||||
// 关闭 Splash 窗口
|
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(() =>
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -205,7 +215,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
@@ -288,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)
|
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
|
||||||
{
|
{
|
||||||
// 优先使用自定义路径(调试模式选择的路径)
|
// 优先使用自定义路径(调试模式选择的路径)
|
||||||
@@ -377,6 +514,9 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||||
|
|
||||||
var hostProcess = Process.Start(processStartInfo);
|
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
|
return (new LauncherResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DesignWidth="600"
|
d:DesignWidth="600"
|
||||||
d:DesignHeight="500"
|
d:DesignHeight="500"
|
||||||
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
||||||
Title="阑山桌面 - 加载详情"
|
Title="LanMountain Desktop - Loading Details"
|
||||||
Width="600"
|
Width="600"
|
||||||
Height="500"
|
Height="500"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
@@ -17,18 +18,17 @@
|
|||||||
Icon="/Assets/logo.ico">
|
Icon="/Assets/logo.ico">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
<!-- 标题栏 -->
|
<Border Grid.Row="0"
|
||||||
<Border Grid.Row="0"
|
|
||||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
Padding="20,16">
|
Padding="20,16">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="正在启动阑山桌面"
|
<TextBlock Text="Starting LanMountain Desktop"
|
||||||
FontSize="18"
|
FontSize="18"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||||
<TextBlock x:Name="SubtitleText"
|
<TextBlock x:Name="SubtitleText"
|
||||||
Text="初始化系统组件..."
|
Text="Initializing..."
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
|
||||||
<Grid Grid.Row="1" Margin="16,12">
|
<Grid Grid.Row="1" Margin="16,12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
@@ -54,7 +53,6 @@
|
|||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- 整体进度条 -->
|
|
||||||
<ProgressBar x:Name="OverallProgressBar"
|
<ProgressBar x:Name="OverallProgressBar"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Height="8"
|
Height="8"
|
||||||
@@ -64,14 +62,12 @@
|
|||||||
CornerRadius="4"
|
CornerRadius="4"
|
||||||
Margin="0,0,0,16"/>
|
Margin="0,0,0,16"/>
|
||||||
|
|
||||||
<!-- 当前活动项 -->
|
|
||||||
<Border Grid.Row="1"
|
<Border Grid.Row="1"
|
||||||
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
||||||
CornerRadius="8"
|
CornerRadius="8"
|
||||||
Padding="16,12"
|
Padding="16,12"
|
||||||
Margin="0,0,0,12">
|
Margin="0,0,0,12">
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||||
<!-- 图标 -->
|
|
||||||
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
||||||
Width="40"
|
Width="40"
|
||||||
Height="40"
|
Height="40"
|
||||||
@@ -88,23 +84,20 @@
|
|||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 名称 -->
|
|
||||||
<TextBlock x:Name="CurrentItemName"
|
<TextBlock x:Name="CurrentItemName"
|
||||||
Grid.Row="0" Grid.Column="1"
|
Grid.Row="0" Grid.Column="1"
|
||||||
Text="正在初始化..."
|
Text="Initializing..."
|
||||||
FontSize="15"
|
FontSize="15"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||||
|
|
||||||
<!-- 描述 -->
|
|
||||||
<TextBlock x:Name="CurrentItemDescription"
|
<TextBlock x:Name="CurrentItemDescription"
|
||||||
Grid.Row="1" Grid.Column="1"
|
Grid.Row="1" Grid.Column="1"
|
||||||
Text="准备加载系统组件"
|
Text="Preparing components"
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
Margin="0,4,0,0"/>
|
Margin="0,4,0,0"/>
|
||||||
|
|
||||||
<!-- 进度 -->
|
|
||||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||||
<ProgressBar x:Name="CurrentItemProgress"
|
<ProgressBar x:Name="CurrentItemProgress"
|
||||||
Height="4"
|
Height="4"
|
||||||
@@ -116,15 +109,13 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 加载项列表 -->
|
|
||||||
<Border Grid.Row="2"
|
<Border Grid.Row="2"
|
||||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
CornerRadius="8">
|
CornerRadius="8">
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
<!-- 列表标题 -->
|
|
||||||
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="加载项"
|
Text="Loading Items"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
@@ -135,22 +126,20 @@
|
|||||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
Margin="0,0,4,0"/>
|
Margin="0,0,4,0"/>
|
||||||
<TextBlock Grid.Column="2"
|
<TextBlock Grid.Column="2"
|
||||||
Text="已完成"
|
Text="Done"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- 列表内容 -->
|
|
||||||
<ScrollViewer Grid.Row="1"
|
<ScrollViewer Grid.Row="1"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
Margin="8,0,8,8">
|
Margin="8,0,8,8">
|
||||||
<ItemsControl x:Name="LoadingItemsList">
|
<ItemsControl x:Name="LoadingItemsList">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||||
Margin="4,3"
|
Margin="4,3"
|
||||||
Opacity="{Binding Opacity}">
|
Opacity="{Binding Opacity}">
|
||||||
<!-- 状态图标 -->
|
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="{Binding StatusIcon}"
|
Text="{Binding StatusIcon}"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
@@ -159,7 +148,6 @@
|
|||||||
Margin="0,0,8,0"
|
Margin="0,0,8,0"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
<!-- 名称 -->
|
|
||||||
<TextBlock Grid.Column="1"
|
<TextBlock Grid.Column="1"
|
||||||
Text="{Binding Name}"
|
Text="{Binding Name}"
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
@@ -167,7 +155,6 @@
|
|||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
<!-- 进度 -->
|
|
||||||
<TextBlock Grid.Column="2"
|
<TextBlock Grid.Column="2"
|
||||||
Text="{Binding ProgressText}"
|
Text="{Binding ProgressText}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
@@ -175,7 +162,6 @@
|
|||||||
Margin="8,0"
|
Margin="8,0"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
<!-- 类型标签 -->
|
|
||||||
<Border Grid.Column="3"
|
<Border Grid.Column="3"
|
||||||
Background="{Binding TypeBackground}"
|
Background="{Binding TypeBackground}"
|
||||||
CornerRadius="4"
|
CornerRadius="4"
|
||||||
@@ -194,7 +180,6 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- 错误信息区域 -->
|
|
||||||
<Border x:Name="ErrorPanel"
|
<Border x:Name="ErrorPanel"
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||||
@@ -214,14 +199,13 @@
|
|||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<TextBlock x:Name="ErrorText"
|
<TextBlock x:Name="ErrorText"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="加载过程中出现错误"
|
Text="An error occurred while loading."
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
TextWrapping="Wrap"/>
|
TextWrapping="Wrap"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 底部按钮 -->
|
|
||||||
<Border Grid.Row="3"
|
<Border Grid.Row="3"
|
||||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
Padding="16,12">
|
Padding="16,12">
|
||||||
@@ -234,12 +218,12 @@
|
|||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
<Button x:Name="DetailsButton"
|
<Button x:Name="DetailsButton"
|
||||||
Content="查看详情"
|
Content="Details"
|
||||||
Width="90"
|
Width="90"
|
||||||
Height="32"
|
Height="32"
|
||||||
FontSize="13"/>
|
FontSize="13"/>
|
||||||
<Button x:Name="CancelButton"
|
<Button x:Name="CancelButton"
|
||||||
Content="取消"
|
Content="Cancel"
|
||||||
Width="90"
|
Width="90"
|
||||||
Height="32"
|
Height="32"
|
||||||
FontSize="13"/>
|
FontSize="13"/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ public partial class App : Application
|
|||||||
private LauncherIpcClient? _launcherIpcClient;
|
private LauncherIpcClient? _launcherIpcClient;
|
||||||
private LoadingStateManager? _loadingStateManager;
|
private LoadingStateManager? _loadingStateManager;
|
||||||
private LoadingStateReporter? _loadingStateReporter;
|
private LoadingStateReporter? _loadingStateReporter;
|
||||||
|
private bool _singleInstanceReleased;
|
||||||
|
private int _forcedExitScheduled;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
@@ -290,16 +292,20 @@ public partial class App : Application
|
|||||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
||||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||||
},
|
},
|
||||||
() =>
|
OnDesktopLifetimeExit,
|
||||||
{
|
|
||||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
|
||||||
PerformExitCleanup();
|
|
||||||
},
|
|
||||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||||
StartWeatherLocationRefreshIfNeeded);
|
StartWeatherLocationRefreshIfNeeded);
|
||||||
_desktopShellHost.Initialize(this);
|
_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)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||||
@@ -659,70 +665,102 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void ActivateMainWindow()
|
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)
|
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Send);
|
}, 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()
|
private void EnsureTransparentOverlayWindow()
|
||||||
{
|
{
|
||||||
@@ -885,6 +923,57 @@ public partial class App : Application
|
|||||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
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()
|
private void PerformExitCleanup()
|
||||||
{
|
{
|
||||||
if (_exitCleanupCompleted)
|
if (_exitCleanupCompleted)
|
||||||
@@ -935,6 +1024,22 @@ public partial class App : Application
|
|||||||
disposableRegistry.Dispose();
|
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();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||||
DisposeTrayIcon();
|
DisposeTrayIcon();
|
||||||
@@ -1154,11 +1259,9 @@ public partial class App : Application
|
|||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
// 检查三指滑动功能是否启用
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
if (appSnapshot.EnableThreeFingerSwipe)
|
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
||||||
{
|
{
|
||||||
// 显示透明覆盖层窗口
|
|
||||||
EnsureTransparentOverlayWindow();
|
EnsureTransparentOverlayWindow();
|
||||||
_transparentOverlayWindow?.Show();
|
_transparentOverlayWindow?.Show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
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;
|
public int UpdateDownloadThreads { get; set; } = 4;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
|
|||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -32,11 +33,26 @@ public sealed class Program
|
|||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"Startup",
|
"Startup",
|
||||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||||
|
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
|
|
||||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||||
{
|
{
|
||||||
// 已存在,可能只更新位置或尺寸
|
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
||||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
|
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||||
if (existingWindow.IsVisible == false)
|
if (existingWindow.IsVisible == false)
|
||||||
{
|
{
|
||||||
existingWindow.Show();
|
existingWindow.Show();
|
||||||
|
|||||||
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
|
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
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)
|
public UpdateSettingsService(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.DownloadAssetAsync(
|
return _githubReleaseUpdateService.DownloadAssetAsync(
|
||||||
asset,
|
asset,
|
||||||
destinationFilePath,
|
destinationFilePath,
|
||||||
downloadSource,
|
downloadSource,
|
||||||
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.RedownloadAssetAsync(
|
return _githubReleaseUpdateService.RedownloadAssetAsync(
|
||||||
asset,
|
asset,
|
||||||
destinationFilePath,
|
destinationFilePath,
|
||||||
downloadSource,
|
downloadSource,
|
||||||
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
|
|
||||||
public void Dispose()
|
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
|
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 Mutex _mutex;
|
||||||
private readonly string _pipeName;
|
private readonly string _pipeName;
|
||||||
private readonly CancellationTokenSource _listenCts = new();
|
private readonly CancellationTokenSource _listenCts = new();
|
||||||
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"SingleInstance",
|
||||||
|
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
return TryNotifyPrimaryInstance(timeout, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
|
||||||
{
|
{
|
||||||
if (_ownsMutex || _disposed)
|
if (_ownsMutex || _disposed)
|
||||||
{
|
{
|
||||||
|
failureReason = _ownsMutex
|
||||||
|
? "current_instance_is_primary"
|
||||||
|
: "single_instance_service_disposed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
using var client = new NamedPipeClientStream(
|
using var client = new NamedPipeClientStream(
|
||||||
serverName: ".",
|
serverName: ".",
|
||||||
pipeName: _pipeName,
|
pipeName: _pipeName,
|
||||||
direction: PipeDirection.Out,
|
direction: PipeDirection.InOut,
|
||||||
options: PipeOptions.Asynchronous);
|
options: PipeOptions.Asynchronous);
|
||||||
|
|
||||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||||
client.WriteByte(1);
|
client.WriteByte(ActivationRequestCode);
|
||||||
client.Flush();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
failureReason = "primary_activation_handshake_exception";
|
||||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
{
|
{
|
||||||
using var server = new NamedPipeServerStream(
|
using var server = new NamedPipeServerStream(
|
||||||
_pipeName,
|
_pipeName,
|
||||||
PipeDirection.In,
|
PipeDirection.InOut,
|
||||||
1,
|
1,
|
||||||
PipeTransmissionMode.Byte,
|
PipeTransmissionMode.Byte,
|
||||||
PipeOptions.Asynchronous);
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
var buffer = new byte[1];
|
||||||
onActivationRequested();
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class UpdateSettingsValues
|
|||||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||||
public const string ModeSilentOnExit = "silent_on_exit";
|
public const string ModeSilentOnExit = "silent_on_exit";
|
||||||
|
|
||||||
|
public const string DownloadSourcePdc = "pdc";
|
||||||
public const string DownloadSourceGitHub = "github";
|
public const string DownloadSourceGitHub = "github";
|
||||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||||
|
|
||||||
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
|
|||||||
|
|
||||||
public static string NormalizeDownloadSource(string? value)
|
public static string NormalizeDownloadSource(string? value)
|
||||||
{
|
{
|
||||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||||
? DownloadSourceGhProxy
|
{
|
||||||
: DownloadSourceGitHub;
|
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)
|
public static int NormalizeDownloadThreads(int value)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
@@ -52,9 +53,9 @@ public sealed class UpdateWorkflowService
|
|||||||
private const string LauncherDirectoryName = ".launcher";
|
private const string LauncherDirectoryName = ".launcher";
|
||||||
private const string UpdateDirectoryName = "update";
|
private const string UpdateDirectoryName = "update";
|
||||||
private const string IncomingDirectoryName = "incoming";
|
private const string IncomingDirectoryName = "incoming";
|
||||||
private const string DeltaManifestFileName = "files.json";
|
private const string SignedFileMapName = "files.json";
|
||||||
private const string DeltaSignatureFileName = "files.json.sig";
|
private const string SignedFileMapSignatureName = "files.json.sig";
|
||||||
private const string DeltaArchiveFileName = "update.zip";
|
private const string UpdateArchiveName = "update.zip";
|
||||||
|
|
||||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
@@ -81,8 +82,7 @@ public sealed class UpdateWorkflowService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
||||||
/// Also supports versioned filenames like files-{version}.json, delta-{old}-to-{new}.zip
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||||
{
|
{
|
||||||
@@ -91,73 +91,11 @@ public sealed class UpdateWorkflowService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||||
|
|
||||||
// Check for exact matches first (preferred)
|
|
||||||
var hasExactManifest = assetNames.Contains(DeltaManifestFileName);
|
|
||||||
var hasExactSignature = assetNames.Contains(DeltaSignatureFileName);
|
|
||||||
var hasExactArchive = assetNames.Contains(DeltaArchiveFileName);
|
|
||||||
|
|
||||||
if (hasExactManifest && hasExactSignature && hasExactArchive)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for versioned filenames (e.g., files-1.0.0.json, delta-0.9.9-to-1.0.0.zip)
|
|
||||||
var hasVersionedManifest = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& n.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
|
|
||||||
var hasVersionedSignature = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& n.EndsWith(".sig", StringComparison.OrdinalIgnoreCase));
|
|
||||||
var hasVersionedArchive = assetNames.Any(n => n.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& n.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
return hasVersionedManifest && hasVersionedSignature && hasVersionedArchive;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Finds the best matching delta asset name from the release assets.
|
|
||||||
/// Prefers exact matches, falls back to versioned filenames.
|
|
||||||
/// </summary>
|
|
||||||
private static string? FindDeltaAssetName(GitHubReleaseInfo release, string baseName)
|
|
||||||
{
|
|
||||||
if (release?.Assets is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try exact match first
|
|
||||||
var exactMatch = release.Assets.FirstOrDefault(a =>
|
|
||||||
string.Equals(a.Name, baseName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (exactMatch != null)
|
|
||||||
{
|
|
||||||
return exactMatch.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to pattern matching
|
|
||||||
return baseName.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"files.json" => release.Assets
|
|
||||||
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& a.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderByDescending(a => a.Name.Length)
|
|
||||||
.FirstOrDefault()?.Name,
|
|
||||||
"files.json.sig" => release.Assets
|
|
||||||
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& a.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderByDescending(a => a.Name.Length)
|
|
||||||
.FirstOrDefault()?.Name,
|
|
||||||
"update.zip" => release.Assets
|
|
||||||
.Where(a => a.Name.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderByDescending(a => a.Name.Length)
|
|
||||||
.FirstOrDefault()?.Name,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
|
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
||||||
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||||
UpdateCheckResult checkResult,
|
UpdateCheckResult checkResult,
|
||||||
@@ -171,9 +109,9 @@ public sealed class UpdateWorkflowService
|
|||||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
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();
|
var incomingDir = GetLauncherIncomingDirectory();
|
||||||
@@ -191,55 +129,19 @@ public sealed class UpdateWorkflowService
|
|||||||
var downloadSource = state.UpdateDownloadSource;
|
var downloadSource = state.UpdateDownloadSource;
|
||||||
var downloadThreads = state.UpdateDownloadThreads;
|
var downloadThreads = state.UpdateDownloadThreads;
|
||||||
|
|
||||||
// Find the actual asset names (support both exact and versioned filenames)
|
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||||
var manifestAssetName = FindDeltaAssetName(checkResult.Release, DeltaManifestFileName);
|
|
||||||
var signatureAssetName = FindDeltaAssetName(checkResult.Release, DeltaSignatureFileName);
|
|
||||||
var archiveAssetName = FindDeltaAssetName(checkResult.Release, DeltaArchiveFileName);
|
|
||||||
|
|
||||||
if (manifestAssetName is null || signatureAssetName is null || archiveAssetName is null)
|
|
||||||
{
|
{
|
||||||
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
|
(manifestAsset, SignedFileMapName),
|
||||||
}
|
(signatureAsset, SignedFileMapSignatureName),
|
||||||
|
(archiveAsset, UpdateArchiveName)
|
||||||
// Build asset map with actual names from release
|
|
||||||
var assetMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
[DeltaManifestFileName] = manifestAssetName,
|
|
||||||
[DeltaSignatureFileName] = signatureAssetName,
|
|
||||||
[DeltaArchiveFileName] = archiveAssetName
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
[DeltaManifestFileName] = null!,
|
|
||||||
[DeltaSignatureFileName] = null!,
|
|
||||||
[DeltaArchiveFileName] = null!
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var asset in checkResult.Release.Assets)
|
|
||||||
{
|
|
||||||
// Match by actual asset name
|
|
||||||
foreach (var (key, actualName) in assetMap)
|
|
||||||
{
|
|
||||||
if (string.Equals(asset.Name, actualName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
requiredAssets[key] = asset;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 totalAssets = requiredAssets.Count;
|
||||||
var completedAssets = 0;
|
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
|
// Skip if already downloaded and file exists
|
||||||
if (File.Exists(destinationPath))
|
if (File.Exists(destinationPath))
|
||||||
@@ -247,7 +149,7 @@ public sealed class UpdateWorkflowService
|
|||||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
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++;
|
completedAssets++;
|
||||||
progress?.Report((double)completedAssets / totalAssets);
|
progress?.Report((double)completedAssets / totalAssets);
|
||||||
continue;
|
continue;
|
||||||
@@ -271,21 +173,21 @@ public sealed class UpdateWorkflowService
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
// Clean up partially downloaded files
|
// 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 { }
|
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++;
|
completedAssets++;
|
||||||
progress?.Report((double)completedAssets / totalAssets);
|
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
|
SaveState(state with
|
||||||
{
|
{
|
||||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
|
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||||
? null
|
? null
|
||||||
@@ -294,13 +196,13 @@ public sealed class UpdateWorkflowService
|
|||||||
PendingUpdateSha256 = null
|
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>
|
/// <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>
|
/// </summary>
|
||||||
public bool IsPendingDeltaUpdate()
|
public bool IsPendingDeltaUpdate()
|
||||||
{
|
{
|
||||||
@@ -311,11 +213,71 @@ public sealed class UpdateWorkflowService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delta updates are identified by the manifest file path
|
// Incoming payload updates are identified by files.json or incoming directory path.
|
||||||
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
|
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||||
|| pendingPath.Contains(IncomingDirectoryName, 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()
|
public UpdatePendingInfo? GetPendingUpdate()
|
||||||
{
|
{
|
||||||
var state = _settingsFacade.Update.Get();
|
var state = _settingsFacade.Update.Get();
|
||||||
|
|||||||
@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||||
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _previewChannelText = string.Empty;
|
private string _previewChannelText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pdcSourceText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _gitHubSourceText = string.Empty;
|
private string _gitHubSourceText = string.Empty;
|
||||||
|
|
||||||
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
public bool IsPreviewChannelSelected =>
|
public bool IsPreviewChannelSelected =>
|
||||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public bool IsPdcSourceSelected =>
|
||||||
|
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsGitHubSourceSelected =>
|
public bool IsGitHubSourceSelected =>
|
||||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SelectPdcSource()
|
||||||
|
{
|
||||||
|
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectGitHubSource()
|
private void SelectGitHubSource()
|
||||||
{
|
{
|
||||||
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
DownloadProgressValue = 0;
|
DownloadProgressValue = 0;
|
||||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
UpdateStatus = isForce
|
UpdateStatus = isForce
|
||||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
: L("settings.update.status_checking", "Checking update source...");
|
||||||
|
|
||||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||||
_lastCheckResult = result.Success ? result : null;
|
_lastCheckResult = result.Success ? result : null;
|
||||||
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
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.");
|
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");
|
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");
|
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
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");
|
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||||
|
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||||
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
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(
|
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||||
"settings.update.source_ghproxy_desc",
|
"settings.update.source_ghproxy_desc",
|
||||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||||
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
|
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
ComponentContainer.Child = componentContent;
|
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)
|
protected override void OnOpened(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnOpened(e);
|
base.OnOpened(e);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
|
|||||||
public partial class TransparentOverlayWindow : Window
|
public partial class TransparentOverlayWindow : Window
|
||||||
{
|
{
|
||||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||||
|
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||||
|
|
||||||
// 滑动状态
|
// 滑动状态
|
||||||
private bool _isSwipeActive;
|
private bool _isSwipeActive;
|
||||||
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||||
_settingsFacade = facade;
|
_settingsFacade = facade;
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_bottomMostService.SetupBottomMost(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
public void SaveLayoutAndHide()
|
public void SaveLayoutAndHide()
|
||||||
{
|
{
|
||||||
SaveLayout();
|
SaveLayout();
|
||||||
|
_regionPassthroughService.ClearInteractiveRegions(this);
|
||||||
Hide();
|
Hide();
|
||||||
|
|
||||||
// Remove all components so that next time we open it builds fresh from snapshot
|
// Remove all components so that next time we open it builds fresh from snapshot
|
||||||
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
RenderAllComponents();
|
RenderAllComponents();
|
||||||
|
|
||||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_bottomMostService.SendToBottom(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateInteractiveRegions()
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -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.
|
**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.
|
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`.
|
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.
|
**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)
|
- [Launcher 架构文档](LAUNCHER.md)
|
||||||
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
- [构建和部署指南](BUILD_AND_DEPLOY.md)
|
||||||
- [故障排除指南](TROUBLESHOOTING.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,105 +1,165 @@
|
|||||||
# Generate-DeltaPackage.ps1
|
|
||||||
# 生成增量更新包 (delta.zip + files.json)
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PreviousVersion,
|
[string]$PreviousVersion,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$CurrentVersion,
|
[string]$CurrentVersion,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PreviousDir,
|
[string]$PreviousDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$CurrentDir,
|
[string]$CurrentDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$OutputDir
|
[string]$OutputDir
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
Add-Type -AssemblyName System.IO.Compression
|
||||||
Write-Host "从版本: $PreviousVersion"
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
Write-Host "到版本: $CurrentVersion"
|
|
||||||
Write-Host "上一版本目录: $PreviousDir"
|
|
||||||
Write-Host "当前版本目录: $CurrentDir"
|
|
||||||
Write-Host "输出目录: $OutputDir"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 确保输出目录存在
|
function Get-NormalizedRelativePath {
|
||||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$RootDir,
|
||||||
|
|
||||||
# 计算文件 SHA256
|
[Parameter(Mandatory = $true)]
|
||||||
function Get-FileSha256 {
|
[string]$FullPath
|
||||||
param([string]$Path)
|
)
|
||||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
|
||||||
return $hash.Hash.ToLower()
|
$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 {
|
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 = @{}
|
$manifest = @{}
|
||||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
|
||||||
|
|
||||||
foreach ($file in $files) {
|
foreach ($file in $files) {
|
||||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
|
||||||
$relativePath = $relativePath.Replace('\', '/')
|
$manifest[$relativePath] = [ordered]@{
|
||||||
|
|
||||||
$manifest[$relativePath] = @{
|
|
||||||
Path = $relativePath
|
Path = $relativePath
|
||||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
Sha256 = Get-FileSha256Hex -Path $file.FullName
|
||||||
Size = $file.Length
|
Size = [long]$file.Length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $manifest
|
return $manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
function New-DeltaArchive {
|
||||||
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
|
param(
|
||||||
if (-not (Test-Path $PreviousDir)) {
|
[Parameter(Mandatory = $true)]
|
||||||
throw "Previous directory does not exist: $PreviousDir"
|
[string]$ZipPath,
|
||||||
|
|
||||||
|
[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
|
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||||
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
|
|
||||||
|
|
||||||
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
|
|
||||||
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
|
|
||||||
if (-not (Test-Path $CurrentDir)) {
|
|
||||||
throw "Current directory does not exist: $CurrentDir"
|
|
||||||
}
|
|
||||||
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||||
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 分析文件变更
|
|
||||||
$changedFiles = @()
|
$changedFiles = @()
|
||||||
$reusedFiles = @()
|
$reusedFiles = @()
|
||||||
$deletedFiles = @()
|
$deletedFiles = @()
|
||||||
|
|
||||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
|
||||||
|
|
||||||
# 检查新增和修改的文件
|
|
||||||
foreach ($path in $currentManifest.Keys) {
|
|
||||||
$currentFile = $currentManifest[$path]
|
$currentFile = $currentManifest[$path]
|
||||||
|
|
||||||
if ($previousManifest.ContainsKey($path)) {
|
if ($previousManifest.ContainsKey($path)) {
|
||||||
$previousFile = $previousManifest[$path]
|
$previousFile = $previousManifest[$path]
|
||||||
|
|
||||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||||
# 文件未变更,可以复用
|
$reusedFiles += [ordered]@{
|
||||||
$reusedFiles += @{
|
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "reuse"
|
Action = "reuse"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
Size = $currentFile.Size
|
Size = $currentFile.Size
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
# 文件已修改
|
else {
|
||||||
$changedFiles += @{
|
$changedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "replace"
|
Action = "replace"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
@@ -107,9 +167,9 @@ foreach ($path in $currentManifest.Keys) {
|
|||||||
ArchivePath = $path
|
ArchivePath = $path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
# 新增文件
|
else {
|
||||||
$changedFiles += @{
|
$changedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "add"
|
Action = "add"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
@@ -119,104 +179,51 @@ foreach ($path in $currentManifest.Keys) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查删除的文件
|
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
|
||||||
foreach ($path in $previousManifest.Keys) {
|
|
||||||
if (-not $currentManifest.ContainsKey($path)) {
|
if (-not $currentManifest.ContainsKey($path)) {
|
||||||
$deletedFiles += @{
|
$deletedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "delete"
|
Action = "delete"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "变更统计:" -ForegroundColor Green
|
Write-Host "Changed: $($changedFiles.Count)"
|
||||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
Write-Host "Reused: $($reusedFiles.Count)"
|
||||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
Write-Host "Deleted: $($deletedFiles.Count)"
|
||||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 显示前10个变更的文件(用于调试)
|
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
|
||||||
if ($changedFiles.Count -gt 0) {
|
|
||||||
Write-Host "变更的文件示例:" -ForegroundColor Cyan
|
|
||||||
$changedFiles | Select-Object -First 10 | ForEach-Object {
|
|
||||||
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
if ($changedFiles.Count -gt 10) {
|
|
||||||
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# 创建临时目录用于打包
|
|
||||||
$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
|
|
||||||
|
|
||||||
# 复制变更的文件到临时目录
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
# 创建 update.zip (Launcher 期望的文件名)
|
|
||||||
$updateZipPath = Join-Path $OutputDir "update.zip"
|
$updateZipPath = Join-Path $OutputDir "update.zip"
|
||||||
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
|
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
|
||||||
|
|
||||||
if (Test-Path $updateZipPath) {
|
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
|
||||||
Remove-Item -Path $updateZipPath -Force
|
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
|
||||||
}
|
|
||||||
|
|
||||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||||
|
$filesJson = [ordered]@{
|
||||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
|
||||||
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
|
|
||||||
Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow
|
|
||||||
if (Test-Path $deltaZipPath) {
|
|
||||||
Remove-Item -Path $deltaZipPath -Force
|
|
||||||
}
|
|
||||||
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
|
|
||||||
|
|
||||||
# 清理临时目录
|
|
||||||
Remove-Item -Path $tempDir -Recurse -Force
|
|
||||||
|
|
||||||
# 生成 files.json (Launcher 期望的文件名)
|
|
||||||
$filesJson = @{
|
|
||||||
FromVersion = $PreviousVersion
|
FromVersion = $PreviousVersion
|
||||||
ToVersion = $CurrentVersion
|
ToVersion = $CurrentVersion
|
||||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
|
||||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
Files = $allEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$jsonText = $filesJson | ConvertTo-Json -Depth 10
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
|
||||||
$filesJsonPath = Join-Path $OutputDir "files.json"
|
$filesJsonPath = Join-Path $OutputDir "files.json"
|
||||||
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
|
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
|
||||||
|
|
||||||
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
|
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
|
||||||
|
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||||
|
|
||||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
|
||||||
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
|
||||||
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
|
|
||||||
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
|
||||||
|
|
||||||
# 计算增量包大小
|
|
||||||
$updateSize = (Get-Item $updateZipPath).Length
|
|
||||||
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
|
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
Write-Host "Done."
|
||||||
Write-Host "增量包大小: $updateSizeMB MB"
|
Write-Host "update.zip size: $updateSizeMb MB"
|
||||||
Write-Host "输出文件 (Launcher 使用):"
|
Write-Host "Generated:"
|
||||||
Write-Host " - $updateZipPath"
|
Write-Host " $updateZipPath"
|
||||||
Write-Host " - $filesJsonPath"
|
Write-Host " $filesJsonPath"
|
||||||
Write-Host "输出文件 (GitHub Release 发布):"
|
Write-Host " $deltaZipPath"
|
||||||
Write-Host " - $deltaZipPath"
|
Write-Host " $versionedFilesJsonPath"
|
||||||
Write-Host " - $versionedFilesJsonPath"
|
|
||||||
|
|||||||
@@ -1,65 +1,56 @@
|
|||||||
# Sign-FileMap.ps1
|
|
||||||
# 对 files.json 进行 RSA 签名
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$FilesJsonPath,
|
[string]$FilesJsonPath,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PrivateKeyPath,
|
[string]$PrivateKeyPath,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory = $false)]
|
||||||
[string]$OutputPath
|
[string]$OutputPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
Write-Host "文件清单: $FilesJsonPath"
|
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
|
||||||
Write-Host "私钥: $PrivateKeyPath"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 检查文件是否存在
|
|
||||||
if (-not (Test-Path $FilesJsonPath)) {
|
|
||||||
Write-Error "文件清单不存在: $FilesJsonPath"
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $PrivateKeyPath)) {
|
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
|
||||||
Write-Error "私钥文件不存在: $PrivateKeyPath"
|
throw "Manifest file not found: $FilesJsonPath"
|
||||||
exit 1
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
|
||||||
|
throw "Private key file not found: $PrivateKeyPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 确定输出路径
|
|
||||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
$OutputPath = "$FilesJsonPath.sig"
|
$OutputPath = "$FilesJsonPath.sig"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 读取文件内容
|
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
|
||||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
|
||||||
|
|
||||||
# 读取私钥
|
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
|
||||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||||
|
throw "Private key PEM is empty: $PrivateKeyPath"
|
||||||
# 使用 .NET 进行 RSA 签名
|
}
|
||||||
Add-Type -AssemblyName System.Security.Cryptography
|
|
||||||
|
|
||||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
$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()
|
||||||
|
}
|
||||||
|
|
||||||
# 生成签名
|
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
||||||
$signature = $rsa.SignData(
|
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
|
||||||
$jsonBytes,
|
|
||||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
|
||||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
|
||||||
)
|
|
||||||
|
|
||||||
# 转换为 Base64
|
Write-Host "Signed manifest file."
|
||||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
Write-Host "Manifest: $FilesJsonPath"
|
||||||
|
Write-Host "Signature: $OutputPath"
|
||||||
# 写入签名文件
|
|
||||||
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
|
|
||||||
|
|
||||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
|
||||||
Write-Host "签名文件: $OutputPath"
|
|
||||||
Write-Host "签名长度: $($signature.Length) 字节"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user