Compare commits

...

13 Commits

34 changed files with 1757 additions and 613 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -512,3 +512,5 @@ nul
/*.deb /*.deb
/*.dmg /*.dmg
/*.AppImage /*.AppImage
/velopack-output-local-verify
/velopack-output-local

View 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.

View 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.

View 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.

View 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.

View 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.

View 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).

View File

@@ -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-----

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"/>

View File

@@ -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;
}

View 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);
}
}

View File

@@ -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();
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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();

View 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];
}
}

View File

@@ -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);
} }
} }

View File

@@ -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)
{ {

View File

@@ -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)

View File

@@ -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();

View File

@@ -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)
]; ];

View File

@@ -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);

View File

@@ -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>

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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)/"

View File

@@ -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"

View File

@@ -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) 字节"