Compare commits

..

15 Commits

Author SHA1 Message Date
lincube
62e7d96fe7 fix: compare signing keys by SPKI instead of PEM text 2026-04-20 09:15:08 +08:00
lincube
c5ef418bd9 fix: rotate launcher public key to match ci signing secret 2026-04-20 09:05:34 +08:00
lincube
1e6b61db85 fix: normalize PEM line endings in signing key validation 2026-04-20 08:55:45 +08:00
lincube
48ce93b68e fix: sync launcher public key with update signing secret 2026-04-20 08:45:53 +08:00
lincube
cddebbcf5a fix: restore stable launcher update public key 2026-04-20 08:33:14 +08:00
lincube
24b361b5b9 chore: rotate launcher update public key for pdc signing 2026-04-20 08:20:56 +08:00
lincube
833c69305b fix: make delta pack generation robust for empty diffs and linux paths 2026-04-20 08:07:58 +08:00
lincube
858612fa8e fix: make optional s3 upload step workflow-parse safe 2026-04-20 07:55:56 +08:00
lincube
f6a6f97e0b chore: migrate release pipeline to signed filemap and wire rainyun s3 2026-04-20 07:48:53 +08:00
lincube
02547eeea6 feat.引入velopack,不好,是rust(至少内存很安全了。 2026-04-19 20:11:16 +08:00
lincube
8e39ea864f fix.GitHub Action工作流怎么天天出问题 2026-04-19 19:33:45 +08:00
lincube
6343164b24 fix.修ci,修融合桌面,修启动器 2026-04-19 17:02:53 +08:00
lincube
8e21364eed changed.velopack,试试rust 2026-04-19 12:36:14 +08:00
lincube
4f9feafbbe fix.继续修ci,ci怎么天天炸 2026-04-19 02:12:34 +08:00
lincube
9cf3a15c89 fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 2026-04-18 23:36:31 +08:00
47 changed files with 2090 additions and 749 deletions

View File

@@ -32,6 +32,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -68,10 +69,18 @@ jobs:
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
# Ubuntu 24.04+ moved several packages to t64 names.
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
# Prefer modern WebKit package, fallback for older images.
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -98,10 +107,14 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -132,6 +145,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages
shell: pwsh

View File

@@ -25,12 +25,13 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -88,6 +88,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -108,7 +109,7 @@ jobs:
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
# AOT publish
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
@@ -126,7 +127,7 @@ jobs:
exit 1
}
# 显示发布结果
# 鏄剧ず鍙戝竷缁撴灉
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
@@ -134,7 +135,7 @@ jobs:
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
# Warn if unexpected extra files are produced
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files"
@@ -316,176 +317,92 @@ jobs:
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Create App Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Build Signed FileMap Update Package
if: matrix.self_contained == true
run: |
$ErrorActionPreference = "Stop"
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$platform = "windows-$arch"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
$outputDir = Join-Path "delta-output" $platform
$generateScript = "scripts/Generate-DeltaPackage.ps1"
$signScript = "scripts/Sign-FileMap.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# 创建 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"
if (-not (Test-Path $currentAppPath)) {
Write-Error "Expected app directory not found: $currentAppPath"
exit 1
}
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
& $generateScript `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
$privateKeyPem = @'
${{ secrets.PDC_SIGNING_KEY }}
'@.Trim()
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
exit 0
$privateKeyPem = @'
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
'@.Trim()
}
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
exit 1
}
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
}
$privateKeyPath = Join-Path $tempDir "private-key.pem"
$publicKeyPath = Join-Path $tempDir "public-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($privateKeyPem)
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
$repoRsa = [System.Security.Cryptography.RSA]::Create()
$repoRsa.ImportFromPem($repoPublicKeyPem)
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
if ($repoSpki -ne $derivedSpki) {
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
exit 1
}
"@
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
Remove-Item -Path $privateKeyPath -Force
& $signScript `
-FilesJsonPath (Join-Path $outputDir "files.json") `
-PrivateKeyPath $privateKeyPath `
-OutputPath (Join-Path $outputDir "files.json.sig")
Write-Host "Signed files.json -> files.json.sig"
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
shell: pwsh
- name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Upload Signed FileMap Update Package
if: matrix.self_contained == true
uses: actions/upload-artifact@v4
with:
name: release-delta-windows-x64
name: release-update-windows-${{ matrix.arch }}
path: |
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
delta-output/app-*.zip
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
@@ -517,10 +434,18 @@ jobs:
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
# Ubuntu 24.04+ moved several packages to t64 names.
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
# Prefer modern WebKit package, fallback for older images.
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -683,6 +608,90 @@ jobs:
exit 1
fi
- name: Build Signed FileMap Update Package
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ needs.prepare.outputs.version }}"
$platform = "linux-x64"
$publishDir = "publish/linux-x64"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = Join-Path "delta-output" $platform
$generateScript = "scripts/Generate-DeltaPackage.ps1"
$signScript = "scripts/Sign-FileMap.ps1"
if (-not (Test-Path $currentAppPath)) {
Write-Error "Expected app directory not found: $currentAppPath"
exit 1
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
& $generateScript `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
$privateKeyPem = @'
${{ secrets.PDC_SIGNING_KEY }}
'@.Trim()
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
$privateKeyPem = @'
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
'@.Trim()
}
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
exit 1
}
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
$privateKeyPath = Join-Path $tempDir "private-key.pem"
$publicKeyPath = Join-Path $tempDir "public-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($privateKeyPem)
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
$repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw
$repoRsa = [System.Security.Cryptography.RSA]::Create()
$repoRsa.ImportFromPem($repoPublicKeyPem)
$repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo())
$derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo())
if ($repoSpki -ne $derivedSpki) {
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
exit 1
}
& $signScript `
-FilesJsonPath (Join-Path $outputDir "files.json") `
-PrivateKeyPath $privateKeyPath `
-OutputPath (Join-Path $outputDir "files.json.sig")
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
- name: Upload Signed FileMap Update Package
uses: actions/upload-artifact@v4
with:
name: release-update-linux-x64
path: |
delta-output/linux-x64/files-linux-x64.json
delta-output/linux-x64/files-linux-x64.json.sig
delta-output/linux-x64/update-linux-x64.zip
if-no-files-found: error
retention-days: 90
- name: Upload
uses: actions/upload-artifact@v4
with:
@@ -707,10 +716,14 @@ jobs:
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -881,10 +894,8 @@ jobs:
mkdir -p release-files
# Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
# Copy app package for future delta generation (app-{version}-win-{arch}.zip)
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
# Copy signed file-map incremental update assets
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
@@ -897,6 +908,44 @@ jobs:
exit 1
fi
- name: Upload Incremental Assets to S3 (optional)
if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' }}
env:
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_OBJECT_PREFIX: lanmountain/distribution-v1
run: |
set -euo pipefail
if [ -z "${S3_ACCESS_KEY:-}" ] || [ -z "${S3_SECRET_KEY:-}" ]; then
echo "S3 credentials are not configured. Skipping optional S3 upload step."
exit 0
fi
python3 -m pip install --upgrade awscli
mkdir -p release-update-assets
find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \;
asset_count=$(find release-update-assets -type f | wc -l)
if [ "$asset_count" -eq 0 ]; then
echo "Error: no incremental update assets found for S3 upload."
exit 1
fi
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
export AWS_DEFAULT_REGION="$S3_REGION"
version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/"
latest_prefix="${S3_OBJECT_PREFIX}/latest/"
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors
- name: Create Release
uses: ncipollo/release-action@v1
with:
@@ -918,12 +967,12 @@ jobs:
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
### Incremental Update Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
Existing users: The app will automatically detect and apply the incremental update on next launch.
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)

2
.gitignore vendored
View File

@@ -512,3 +512,5 @@ nul
/*.deb
/*.dmg
/*.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

@@ -0,0 +1,23 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -1,8 +1,11 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
-----END RSA PUBLIC KEY-----
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
FQiGowgqx0l5AgMBAAE=
-----END RSA PUBLIC KEY-----

View File

@@ -56,7 +56,11 @@
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -91,11 +91,7 @@ internal static class Commands
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
@@ -106,6 +102,15 @@ internal static class Commands
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
@@ -149,10 +154,7 @@ internal static class Commands
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}

View File

@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;

View File

@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
/// <summary>
/// 发现配置文件
/// </summary>
private class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);

View File

@@ -9,6 +9,15 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
@@ -167,21 +176,31 @@ internal sealed class LauncherFlowCoordinator
var processExitTask = hostProcess.WaitForExitAsync();
// 等待主程序就绪或进程退出(取先发生者)
// 延长超时到 120 秒,给主程序足够的加载时间
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
var readyOrTimeoutOrExit = Task.WhenAny(
hostReadyTcs.Task,
processExitTask,
Task.Delay(TimeSpan.FromSeconds(120)));
Task.Delay(TimeSpan.FromSeconds(30)));
var completedTask = await readyOrTimeoutOrExit;
// 检查是否是进程先退出(异常情况)
// Host process exited before reporting Ready.
if (completedTask == processExitTask)
{
var exitCode = hostProcess.ExitCode;
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
// 关闭 Splash 窗口
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
exitCode,
hostReadyTcs,
splashWindow,
loadingDetailsWindow).ConfigureAwait(false);
if (recoveryResult is not null)
{
return recoveryResult;
}
// Close Splash window for unrecoverable early exits.
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
@@ -196,7 +215,7 @@ internal sealed class LauncherFlowCoordinator
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
}
});
return new LauncherResult
{
Success = false,
@@ -279,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)
{
// 优先使用自定义路径(调试模式选择的路径)
@@ -315,36 +461,62 @@ internal sealed class LauncherFlowCoordinator
EnsureExecutable(hostPath);
}
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
var versionInfo = _deploymentLocator.GetVersionInfo();
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0)
// 避免子进程窗口创建成功但不可见的问题。
var arguments = new System.Text.StringBuilder();
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid
foreach (var arg in _context.RawArgs)
{
if (arg == _context.Command || arg == _context.SubCommand)
continue;
if (arg.StartsWith("--"))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0) key = key[..equalsIndex];
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
continue;
}
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append(QuoteArgument(arg));
}
// 通过命令行参数传递 IPC 连接信息UseShellExecute=true 时不支持 EnvironmentVariables
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
UseShellExecute = true,
WorkingDirectory = hostWorkingDir,
Arguments = arguments.ToString()
};
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
var hostProcess = Process.Start(processStartInfo);
Console.WriteLine(
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
return (new LauncherResult
{
Success = true,
@@ -483,6 +655,36 @@ internal sealed class LauncherFlowCoordinator
return result;
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())

View File

@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");

View File

@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
{
WriteIndented = true
}));
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
}
return new LauncherResult
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
internal sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName)
{
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
/// <summary>
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
// GitHub API 响应模型
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}
}
// GitHub API 响应模型
internal sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}

View File

@@ -48,7 +48,7 @@ internal sealed class UpdateEngineService
}
var fileMapText = File.ReadAllText(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null)
{
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
@@ -137,7 +137,7 @@ internal sealed class UpdateEngineService
}
var fileMapText = await File.ReadAllTextAsync(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null || fileMap.Files.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
@@ -438,7 +438,7 @@ internal sealed class UpdateEngineService
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
@@ -656,10 +656,7 @@ internal sealed class UpdateEngineService
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
{
WriteIndented = true
}));
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
private static LauncherResult Failed(string stage, string code, string message)

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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="阑山桌面 - 加载详情"
Title="LanMountain Desktop - Loading Details"
Width="600"
Height="500"
WindowStartupLocation="CenterScreen"
@@ -17,18 +18,17 @@
Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题栏 -->
<Border Grid.Row="0"
<Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="正在启动阑山桌面"
<TextBlock Text="Starting LanMountain Desktop"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText"
Text="初始化系统组件..."
Text="Initializing..."
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
@@ -46,7 +46,6 @@
</Grid>
</Border>
<!-- 主要内容区域 -->
<Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -54,7 +53,6 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 整体进度条 -->
<ProgressBar x:Name="OverallProgressBar"
Grid.Row="0"
Height="8"
@@ -64,14 +62,12 @@
CornerRadius="4"
Margin="0,0,0,16"/>
<!-- 当前活动项 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8"
Padding="16,12"
Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 图标 -->
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40"
Height="40"
@@ -88,23 +84,20 @@
VerticalAlignment="Center"/>
</Border>
<!-- 名称 -->
<TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1"
Text="正在初始化..."
Text="Initializing..."
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<!-- 描述 -->
<TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1"
Text="准备加载系统组件"
Text="Preparing components"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/>
<!-- 进度 -->
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress"
Height="4"
@@ -116,15 +109,13 @@
</Grid>
</Border>
<!-- 加载项列表 -->
<Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<!-- 列表标题 -->
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
Text="加载项"
Text="Loading Items"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
@@ -135,22 +126,20 @@
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/>
<TextBlock Grid.Column="2"
Text="已完成"
Text="Done"
FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid>
<!-- 列表内容 -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
<DataTemplate DataType="views:LoadingItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3"
Opacity="{Binding Opacity}">
<!-- 状态图标 -->
<TextBlock Grid.Column="0"
Text="{Binding StatusIcon}"
FontSize="14"
@@ -159,7 +148,6 @@
Margin="0,0,8,0"
VerticalAlignment="Center"/>
<!-- 名称 -->
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="13"
@@ -167,7 +155,6 @@
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<!-- 进度 -->
<TextBlock Grid.Column="2"
Text="{Binding ProgressText}"
FontSize="12"
@@ -175,7 +162,6 @@
Margin="8,0"
VerticalAlignment="Center"/>
<!-- 类型标签 -->
<Border Grid.Column="3"
Background="{Binding TypeBackground}"
CornerRadius="4"
@@ -194,7 +180,6 @@
</Border>
</Grid>
<!-- 错误信息区域 -->
<Border x:Name="ErrorPanel"
Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
@@ -214,14 +199,13 @@
VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText"
Grid.Column="1"
Text="加载过程中出现错误"
Text="An error occurred while loading."
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- 底部按钮 -->
<Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12">
@@ -234,12 +218,12 @@
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton"
Content="查看详情"
Content="Details"
Width="90"
Height="32"
FontSize="13"/>
<Button x:Name="CancelButton"
Content="取消"
Content="Cancel"
Width="90"
Height="32"
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 LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased;
private int _forcedExitScheduled;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
@@ -142,7 +144,7 @@ public partial class App : Application
EnsureNotificationService();
}
public override async void OnFrameworkInitializationCompleted()
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
@@ -152,12 +154,8 @@ public partial class App : Application
AppLogger.Info("App", "Framework initialization completed.");
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
await InitializeLauncherIpcAsync();
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -166,6 +164,10 @@ public partial class App : Application
}
base.OnFrameworkInitializationCompleted();
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
// 使用 fire-and-forget 模式,不阻塞主流程
_ = InitializeLauncherIpcAsync();
}
private async Task InitializeLauncherIpcAsync()
@@ -189,9 +191,10 @@ public partial class App : Application
// 注册系统初始化加载项
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
_loadingStateManager.StartItem("system.init", "正在连接启动器...");
_loadingStateManager.StartItem("system.init", "连接启动器");
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
}
}
catch (Exception ex)
@@ -227,7 +230,7 @@ public partial class App : Application
}
/// <summary>
/// 同步向 Launcher 报告启动进度,确保关键消息可靠送达
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
/// 用于 Ready 等关键状态报告
/// </summary>
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
@@ -237,27 +240,27 @@ public partial class App : Application
try
{
// 使用同步等待确保消息发送完成
var task = _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
_ = Task.Run(async () =>
{
Stage = stage,
ProgressPercent = percent,
Message = message
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = percent,
Message = message
});
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
});
// 等待最多 5 秒,确保消息发送成功
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
AppLogger.Warn("LauncherIpc", "Report progress timeout after 5 seconds");
}
else
{
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress synchronously: {ex.Message}");
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
}
}
@@ -289,16 +292,20 @@ public partial class App : Application
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
},
() =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
},
OnDesktopLifetimeExit,
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
StartWeatherLocationRefreshIfNeeded);
_desktopShellHost.Initialize(this);
}
private void OnDesktopLifetimeExit()
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
ScheduleForcedProcessTermination("DesktopLifetimeExit");
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
@@ -658,70 +665,102 @@ public partial class App : Application
private void ActivateMainWindow()
{
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
try
{
var restored = Dispatcher.UIThread.CheckAccess()
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
: Dispatcher.UIThread.InvokeAsync(
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
DispatcherPriority.Send).GetAwaiter().GetResult();
if (!restored)
{
throw new InvalidOperationException("Main window restore failed in activation callback.");
}
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
throw;
}
}
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
try
{
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
}
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
}, DispatcherPriority.Send);
}
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
return false;
}
try
{
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
return false;
}
}
private void EnsureTransparentOverlayWindow()
{
@@ -884,6 +923,57 @@ public partial class App : Application
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
}
private void ReleaseSingleInstanceAfterExit(string source)
{
if (_singleInstanceReleased)
{
return;
}
_singleInstanceReleased = true;
var singleInstance = CurrentSingleInstanceService;
CurrentSingleInstanceService = null;
if (singleInstance is null)
{
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
return;
}
try
{
singleInstance.Dispose();
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
}
}
private void ScheduleForcedProcessTermination(string source)
{
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(8)).ConfigureAwait(false);
AppLogger.Warn(
"DesktopShell",
$"Process did not terminate after desktop exit cleanup. Forcing process exit. Source='{source}'; ShutdownIntent='{_shutdownIntent}'.");
Environment.Exit(0);
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Forced process termination scheduler failed. Source='{source}'.", ex);
}
});
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
@@ -934,6 +1024,22 @@ public partial class App : Application
disposableRegistry.Dispose();
}
if (_transparentOverlayWindow is not null)
{
try
{
_transparentOverlayWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
}
finally
{
_transparentOverlayWindow = null;
}
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
@@ -980,6 +1086,27 @@ public partial class App : Application
// 使用 Opened 事件确保所有资源已加载完毕
mainWindow.Opened += OnMainWindowOpened;
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
}
catch { }
}
});
return mainWindow;
}
@@ -1132,11 +1259,9 @@ public partial class App : Application
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
// 检查三指滑动功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (appSnapshot.EnableThreeFingerSwipe)
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
{
// 显示透明覆盖层窗口
EnsureTransparentOverlayWindow();
_transparentOverlayWindow?.Show();
}

View File

@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
public string UpdateMode { get; set; } = "download_then_confirm";
public string UpdateDownloadSource { get; set; } = "github";
public string UpdateDownloadSource { get; set; } = "pdc";
public int UpdateDownloadThreads { get; set; } = 4;

View File

@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop;
@@ -32,11 +33,26 @@ public sealed class Program
AppLogger.Warn(
"Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
return;
}
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
if (activationAcknowledged)
{
AppLogger.Info(
"Startup",
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
}
else
{
AppLogger.Warn(
"Startup",
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
}
return;
}

View File

@@ -100,12 +100,15 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -122,13 +125,16 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -145,11 +151,61 @@ public static class AppRestartService
}
}
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
{
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;

View File

@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{
// 已存在,可能只更新位置或尺寸
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false)
{
existingWindow.Show();

View File

@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
private bool _isConnected;
private readonly object _writeLock = new();
/// <summary>
/// 是否已连接到 Launcher
/// </summary>
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary>
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
/// <summary>
/// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary>
public static bool IsLaunchedByLauncher()
{
return !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
// 优先检查环境变量
if (!string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
{
return true;
}
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>
foreach (var arg in Environment.GetCommandLineArgs())
{
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public void Dispose()

View File

@@ -1,4 +1,5 @@
using System.Timers;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;

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
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.DownloadAssetAsync(
return _githubReleaseUpdateService.DownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
return _githubReleaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_releaseUpdateService.Dispose();
_githubReleaseUpdateService.Dispose();
_pdcReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
{
var pdcResult = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (pdcResult.Success)
{
return pdcResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
}
return isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
}

View File

@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private const byte ActivationRequestCode = 0x41; // 'A'
private const byte ActivationAckCode = 0x4B; // 'K'
private const byte ActivationNackCode = 0x4E; // 'N'
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
return;
}
AppLogger.Info(
"SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
return TryNotifyPrimaryInstance(timeout, out _);
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
{
if (_ownsMutex || _disposed)
{
failureReason = _ownsMutex
? "current_instance_is_primary"
: "single_instance_service_disposed";
return false;
}
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.WriteByte(ActivationRequestCode);
client.Flush();
var ack = client.ReadByte();
var acknowledged = ack == ActivationAckCode;
if (!acknowledged)
{
failureReason = ack switch
{
ActivationNackCode => "primary_rejected_activation",
-1 => "ack_not_received",
_ => $"unexpected_ack_code_{ack}"
};
AppLogger.Warn(
"SingleInstance",
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return false;
}
failureReason = null;
AppLogger.Info(
"SingleInstance",
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return true;
}
catch (Exception ex)
{
failureReason = "primary_activation_handshake_exception";
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
var ackCode = ActivationAckCode;
if (!isActivationRequest)
{
ackCode = ActivationNackCode;
AppLogger.Warn(
"SingleInstance",
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
}
else
{
try
{
onActivationRequested();
}
catch (Exception ex)
{
ackCode = ActivationNackCode;
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
}
}
var ackBuffer = new[] { ackCode };
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -11,6 +11,7 @@ public static class UpdateSettingsValues
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
public const string DownloadSourcePdc = "pdc";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
? DownloadSourceGhProxy
: DownloadSourceGitHub;
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePdc;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGhProxy;
}
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGitHub;
}
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
return DownloadSourcePdc;
}
public static int NormalizeDownloadThreads(int value)

View File

@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk;
@@ -52,9 +53,9 @@ public sealed class UpdateWorkflowService
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string DeltaManifestFileName = "files.json";
private const string DeltaSignatureFileName = "files.json.sig";
private const string DeltaArchiveFileName = "update.zip";
private const string SignedFileMapName = "files.json";
private const string SignedFileMapSignatureName = "files.json.sig";
private const string UpdateArchiveName = "update.zip";
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -81,8 +82,7 @@ public sealed class UpdateWorkflowService
}
/// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
/// Also supports versioned filenames like files-{version}.json, delta-{old}-to-{new}.zip
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
/// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{
@@ -91,73 +91,11 @@ public sealed class UpdateWorkflowService
return false;
}
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
// 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
};
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
}
/// <summary>
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
/// Downloads signed file-map assets to the Launcher's incoming directory.
/// </summary>
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
UpdateCheckResult checkResult,
@@ -171,9 +109,9 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(false, null, "No update available for delta download.");
}
if (!IsDeltaUpdateAvailable(checkResult.Release))
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
{
return new UpdateDownloadResult(false, null, "Release does not contain delta update assets.");
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
}
var incomingDir = GetLauncherIncomingDirectory();
@@ -191,55 +129,19 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads;
// Find the actual asset names (support both exact and versioned filenames)
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)
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
// Build asset map with actual names from release
var assetMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = manifestAssetName,
[DeltaSignatureFileName] = signatureAssetName,
[DeltaArchiveFileName] = archiveAssetName
(manifestAsset, SignedFileMapName),
(signatureAsset, SignedFileMapSignatureName),
(archiveAsset, UpdateArchiveName)
};
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 completedAssets = 0;
foreach (var (name, asset) in requiredAssets)
foreach (var (asset, destinationFileName) in requiredAssets)
{
var destinationPath = Path.Combine(incomingDir, name);
var destinationPath = Path.Combine(incomingDir, destinationFileName);
// Skip if already downloaded and file exists
if (File.Exists(destinationPath))
@@ -247,7 +149,7 @@ public sealed class UpdateWorkflowService
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Info("UpdateWorkflow", $"Delta asset {name} already downloaded with matching hash, skipping.");
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
continue;
@@ -271,21 +173,21 @@ public sealed class UpdateWorkflowService
if (!result.Success)
{
// Clean up partially downloaded files
foreach (var file in requiredAssets.Keys)
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
{
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
}
return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}");
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
}
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
}
// Save state indicating a delta update is pending
// Save state indicating a signed file-map update is pending.
SaveState(state with
{
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
@@ -294,13 +196,13 @@ public sealed class UpdateWorkflowService
PendingUpdateSha256 = null
});
AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null);
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
}
/// <summary>
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer.
/// Checks whether the pending update is managed by Launcher incoming payload.
/// </summary>
public bool IsPendingDeltaUpdate()
{
@@ -311,11 +213,71 @@ public sealed class UpdateWorkflowService
return false;
}
// Delta updates are identified by the manifest file path
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
// Incoming payload updates are identified by files.json or incoming directory path.
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
private static bool TryResolveDeltaAssets(
IReadOnlyList<GitHubReleaseAsset> assets,
out GitHubReleaseAsset manifestAsset,
out GitHubReleaseAsset signatureAsset,
out GitHubReleaseAsset archiveAsset)
{
manifestAsset = default!;
signatureAsset = default!;
archiveAsset = default!;
if (assets is null || assets.Count == 0)
{
return false;
}
var platformSuffix = GetPlatformAssetSuffix();
var platformManifest = $"files-{platformSuffix}.json";
var platformSignature = $"files-{platformSuffix}.json.sig";
var platformArchive = $"update-{platformSuffix}.zip";
var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
{
return false;
}
manifestAsset = manifestCandidate;
signatureAsset = signatureCandidate;
archiveAsset = archiveCandidate;
return true;
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string name)
{
return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
public UpdatePendingInfo? GetPendingUpdate()
{
var state = _settingsFacade.Update.Get();

View File

@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty]
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty]
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _previewChannelText = string.Empty;
[ObservableProperty]
private string _pdcSourceText = string.Empty;
[ObservableProperty]
private string _gitHubSourceText = string.Empty;
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsPreviewChannelSelected =>
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
public bool IsPdcSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
public bool IsGitHubSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
}
[RelayCommand]
private void SelectPdcSource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
}
[RelayCommand]
private void SelectGitHubSource()
{
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
: L("settings.update.status_checking", "Checking GitHub releases...");
? L("settings.update.status_force_checking", "Force checking update source...")
: L("settings.update.status_checking", "Checking update source...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
_lastCheckResult = result.Success ? result : null;
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
@@ -2112,6 +2124,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
StableChannelText = L("settings.update.channel_stable", "Stable");
PreviewChannelText = L("settings.update.channel_preview", "Preview");
PdcSourceText = L("settings.update.source_pdc", "PDC");
GitHubSourceText = L("settings.update.source_github", "GitHub");
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
ManualModeText = L("settings.update.mode_manual", "Manual Update");
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
{
UpdateSettingsValues.DownloadSourcePdc => L(
"settings.update.source_pdc_desc",
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
UpdateSettingsValues.DownloadSourceGhProxy => L(
"settings.update.source_ghproxy_desc",
"Use the gh-proxy mirror when downloading GitHub release assets."),
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return
[
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
];

View File

@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
ComponentContainer.Child = componentContent;
}
public void UpdateComponentLayout(double width, double height)
{
ComponentContainer.Width = width;
ComponentContainer.Height = height;
if (ComponentContainer.Child is Control child)
{
child.Width = width;
child.Height = height;
}
if (OperatingSystem.IsWindows() && IsVisible)
{
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
}
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);

View File

@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
public partial class TransparentOverlayWindow : Window
{
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
// 滑动状态
private bool _isSwipeActive;
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
_weatherDataService = facade.Weather.GetWeatherInfoService();
_timeZoneService = facade.Region.GetTimeZoneService();
_settingsFacade = facade;
if (OperatingSystem.IsWindows())
{
_bottomMostService.SetupBottomMost(this);
}
}
private readonly ISettingsFacadeService _settingsFacade;
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
public void SaveLayoutAndHide()
{
SaveLayout();
_regionPassthroughService.ClearInteractiveRegions(this);
Hide();
// Remove all components so that next time we open it builds fresh from snapshot
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
RenderAllComponents();
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
if (OperatingSystem.IsWindows())
{
_bottomMostService.SendToBottom(this);
}
}
/// <summary>
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
/// </summary>
private void UpdateInteractiveRegions()
{
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
_interactiveRegions.Clear();
foreach (var host in _componentHosts.Values)
{
var left = Canvas.GetLeft(host);
var top = Canvas.GetTop(host);
var width = host.Width > 0 ? host.Width : host.Bounds.Width;
var height = host.Height > 0 ? host.Height : host.Bounds.Height;
if (width <= 0 || height <= 0)
{
continue;
}
// 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。
_interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24));
}
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
}
/// <summary>

View File

@@ -5,13 +5,18 @@
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>

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.
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`.
**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)
- [构建和部署指南](BUILD_AND_DEPLOY.md)
- [故障排除指南](TROUBLESHOOTING.md)
## VeloPack Packaging (Current)
- Release pipeline now produces VeloPack native assets (
eleases.win.json, *.nupkg, RELEASES).
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.

29
phainon.yml Normal file
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(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$OutputDir
)
$ErrorActionPreference = "Stop"
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
Write-Host "从版本: $PreviousVersion"
Write-Host "到版本: $CurrentVersion"
Write-Host "上一版本目录: $PreviousDir"
Write-Host "当前版本目录: $CurrentDir"
Write-Host "输出目录: $OutputDir"
Write-Host ""
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
# 确保输出目录存在
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
function Get-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootDir,
# 计算文件 SHA256
function Get-FileSha256 {
param([string]$Path)
$hash = Get-FileHash -Path $Path -Algorithm SHA256
return $hash.Hash.ToLower()
[Parameter(Mandatory = $true)]
[string]$FullPath
)
$separator = [System.IO.Path]::DirectorySeparatorChar
$altSeparator = [System.IO.Path]::AltDirectorySeparatorChar
$root = [System.IO.Path]::GetFullPath($RootDir).Replace($altSeparator, $separator).TrimEnd($separator)
$path = [System.IO.Path]::GetFullPath($FullPath).Replace($altSeparator, $separator)
$comparison = if ($separator -eq '\') {
[System.StringComparison]::OrdinalIgnoreCase
}
else {
[System.StringComparison]::Ordinal
}
$rootWithSeparator = "$root$separator"
if ($path.StartsWith($rootWithSeparator, $comparison)) {
$relative = $path.Substring($rootWithSeparator.Length)
}
elseif ($path.Equals($root, $comparison)) {
$relative = ""
}
else {
throw "File path '$path' is not under root '$root'."
}
return $relative.Replace('\', '/')
}
function Get-FileSha256Hex {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
# 获取目录中所有文件的相对路径和哈希
function Get-FileManifest {
param([string]$RootDir)
param(
[Parameter(Mandatory = $true)]
[string]$RootDir
)
if (-not (Test-Path -LiteralPath $RootDir)) {
throw "Directory does not exist: $RootDir"
}
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
$manifest = @{}
$files = Get-ChildItem -Path $RootDir -Recurse -File
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
$manifest[$relativePath] = @{
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
$manifest[$relativePath] = [ordered]@{
Path = $relativePath
Sha256 = Get-FileSha256 -Path $file.FullName
Size = $file.Length
Sha256 = Get-FileSha256Hex -Path $file.FullName
Size = [long]$file.Length
}
}
return $manifest
}
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
if (-not (Test-Path $PreviousDir)) {
throw "Previous directory does not exist: $PreviousDir"
function New-DeltaArchive {
param(
[Parameter(Mandatory = $true)]
[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
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
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
# 分析文件变更
$changedFiles = @()
$reusedFiles = @()
$deletedFiles = @()
Write-Host "分析文件变更..." -ForegroundColor Yellow
# 检查新增和修改的文件
foreach ($path in $currentManifest.Keys) {
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
$currentFile = $currentManifest[$path]
if ($previousManifest.ContainsKey($path)) {
$previousFile = $previousManifest[$path]
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
# 文件未变更,可以复用
$reusedFiles += @{
$reusedFiles += [ordered]@{
Path = $path
Action = "reuse"
Sha256 = $currentFile.Sha256
Size = $currentFile.Size
}
} else {
# 文件已修改
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "replace"
Sha256 = $currentFile.Sha256
@@ -107,9 +167,9 @@ foreach ($path in $currentManifest.Keys) {
ArchivePath = $path
}
}
} else {
# 新增文件
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "add"
Sha256 = $currentFile.Sha256
@@ -119,104 +179,51 @@ foreach ($path in $currentManifest.Keys) {
}
}
# 检查删除的文件
foreach ($path in $previousManifest.Keys) {
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
if (-not $currentManifest.ContainsKey($path)) {
$deletedFiles += @{
$deletedFiles += [ordered]@{
Path = $path
Action = "delete"
}
}
}
Write-Host "变更统计:" -ForegroundColor Green
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
Write-Host " 复用: $($reusedFiles.Count) 个文件"
Write-Host " 删除: $($deletedFiles.Count) 个文件"
Write-Host ""
Write-Host "Changed: $($changedFiles.Count)"
Write-Host "Reused: $($reusedFiles.Count)"
Write-Host "Deleted: $($deletedFiles.Count)"
# 显示前10个变更的文件用于调试
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 期望的文件名)
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
$updateZipPath = Join-Path $OutputDir "update.zip"
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
if (Test-Path $updateZipPath) {
Remove-Item -Path $updateZipPath -Force
}
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
# 同时创建带版本号的副本(用于发布到 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 = @{
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
$filesJson = [ordered]@{
FromVersion = $PreviousVersion
ToVersion = $CurrentVersion
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
Files = @($changedFiles + $reusedFiles + $deletedFiles)
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
Files = $allEntries
}
$jsonText = $filesJson | ConvertTo-Json -Depth 10
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$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
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 计算增量包大小
$updateSize = (Get-Item $updateZipPath).Length
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
Write-Host ""
Write-Host "=== 完成 ===" -ForegroundColor Green
Write-Host "增量包大小: $updateSizeMB MB"
Write-Host "输出文件 (Launcher 使用):"
Write-Host " - $updateZipPath"
Write-Host " - $filesJsonPath"
Write-Host "输出文件 (GitHub Release 发布):"
Write-Host " - $deltaZipPath"
Write-Host " - $versionedFilesJsonPath"
Write-Host "Done."
Write-Host "update.zip size: $updateSizeMb MB"
Write-Host "Generated:"
Write-Host " $updateZipPath"
Write-Host " $filesJsonPath"
Write-Host " $deltaZipPath"
Write-Host " $versionedFilesJsonPath"

View File

@@ -1,65 +1,56 @@
# Sign-FileMap.ps1
# 对 files.json 进行 RSA 签名
param(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$FilesJsonPath,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PrivateKeyPath,
[Parameter(Mandatory=$false)]
[Parameter(Mandatory = $false)]
[string]$OutputPath
)
$ErrorActionPreference = "Stop"
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
Write-Host "文件清单: $FilesJsonPath"
Write-Host "私钥: $PrivateKeyPath"
Write-Host ""
# 检查文件是否存在
if (-not (Test-Path $FilesJsonPath)) {
Write-Error "文件清单不存在: $FilesJsonPath"
exit 1
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
}
if (-not (Test-Path $PrivateKeyPath)) {
Write-Error "私钥文件不存在: $PrivateKeyPath"
exit 1
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
throw "Manifest file not found: $FilesJsonPath"
}
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
throw "Private key file not found: $PrivateKeyPath"
}
# 确定输出路径
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = "$FilesJsonPath.sig"
}
# 读取文件内容
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
# 读取私钥
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
# 使用 .NET 进行 RSA 签名
Add-Type -AssemblyName System.Security.Cryptography
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
throw "Private key PEM is empty: $PrivateKeyPath"
}
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($privateKeyPem)
try {
$rsa.ImportFromPem($privateKeyPem)
$signatureBytes = $rsa.SignData(
$manifestBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
)
}
finally {
$rsa.Dispose()
}
# 生成签名
$signature = $rsa.SignData(
$jsonBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
)
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
# 转换为 Base64
$signatureBase64 = [Convert]::ToBase64String($signature)
# 写入签名文件
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
Write-Host "=== 完成 ===" -ForegroundColor Green
Write-Host "签名文件: $OutputPath"
Write-Host "签名长度: $($signature.Length) 字节"
Write-Host "Signed manifest file."
Write-Host "Manifest: $FilesJsonPath"
Write-Host "Signature: $OutputPath"