mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e7d96fe7 | ||
|
|
c5ef418bd9 | ||
|
|
1e6b61db85 | ||
|
|
48ce93b68e | ||
|
|
cddebbcf5a | ||
|
|
24b361b5b9 | ||
|
|
833c69305b | ||
|
|
858612fa8e | ||
|
|
f6a6f97e0b | ||
|
|
02547eeea6 | ||
|
|
8e39ea864f | ||
|
|
6343164b24 | ||
|
|
8e21364eed | ||
|
|
4f9feafbbe | ||
|
|
9cf3a15c89 | ||
|
|
e8d2575bc1 | ||
|
|
4b897831de | ||
|
|
9283da5940 | ||
|
|
9efa43d92b | ||
|
|
53ff98f66d | ||
|
|
6c526ffdd2 | ||
|
|
3957d81948 | ||
|
|
81ee19f360 | ||
|
|
59c4824425 | ||
|
|
e9ff590d79 | ||
|
|
1aaf6cd0e9 | ||
|
|
2f0c178df2 |
21
.github/workflows/build.yml
vendored
21
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
DOTNET_gcServer: 1
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
@@ -31,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 }}
|
||||
@@ -63,12 +65,22 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
libfontconfig1 libfreetype6 \
|
||||
libx11-6 libxrandr2 libxinerama1 \
|
||||
libxi6 libxcursor1 libxext6
|
||||
libxi6 libxcursor1 libxext6 \
|
||||
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 }}
|
||||
@@ -95,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 }}
|
||||
@@ -129,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
|
||||
|
||||
6
.github/workflows/code-quality.yml
vendored
6
.github/workflows/code-quality.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Quality Check
|
||||
name: Quality Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
DOTNET_gcServer: 1
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -24,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 }}
|
||||
|
||||
559
.github/workflows/release.yml
vendored
559
.github/workflows/release.yml
vendored
@@ -19,6 +19,7 @@ on:
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
DOTNET_gcServer: 1
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -29,7 +30,7 @@ jobs:
|
||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Get release info
|
||||
id: version
|
||||
@@ -67,7 +68,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# 完整版(自包含 .NET 运行时)
|
||||
- arch: x64
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -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 }}
|
||||
@@ -100,7 +101,49 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
|
||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
||||
|
||||
# AOT publish
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./$launcherPublishDir `
|
||||
--self-contained `
|
||||
-r win-$arch `
|
||||
-p:PublishAot=true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Launcher AOT publish failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 鏄剧ず鍙戝竷缁撴灉
|
||||
Write-Host "Launcher published to: $launcherPublishDir"
|
||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
$size = [Math]::Round($exeFile.Length / 1MB, 2)
|
||||
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
|
||||
}
|
||||
|
||||
# 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"
|
||||
$files | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
||||
@@ -140,8 +183,47 @@ jobs:
|
||||
Write-Host "Self-contained: $selfContained"
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y --no-progress
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
|
||||
Write-Host "Restructuring for Launcher mode..."
|
||||
Write-Host "Version: $version"
|
||||
Write-Host "Publish dir: $publishDir"
|
||||
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
|
||||
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
$launcherSource = $launcherPublishDir
|
||||
if (Test-Path $launcherSource) {
|
||||
Write-Host "Copying Launcher to root..."
|
||||
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
|
||||
} else {
|
||||
Write-Warning "Launcher publish dir not found: $launcherSource"
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Write-Host "New directory structure:"
|
||||
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup and 7z
|
||||
run: |
|
||||
choco install innosetup -y --no-progress
|
||||
choco install 7zip -y --no-progress
|
||||
shell: pwsh
|
||||
|
||||
- name: Build Installer
|
||||
@@ -153,24 +235,20 @@ jobs:
|
||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
||||
$outputDir = "build-installer"
|
||||
|
||||
# Verify source directory exists
|
||||
|
||||
if (-not (Test-Path -Path $publishDir)) {
|
||||
Write-Error "Publish directory not found: $publishDir"
|
||||
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create output directory
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
# Verify installer script exists
|
||||
|
||||
if (-not (Test-Path -Path $installerScript)) {
|
||||
Write-Error "Installer script not found: $installerScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find Inno Setup compiler (choco may install a shim in PATH)
|
||||
|
||||
$isccPath = $null
|
||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
if ($isccCommand) {
|
||||
@@ -202,12 +280,11 @@ jobs:
|
||||
Write-Error "Inno Setup compiler not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
Write-Host "Found Inno Setup at: $isccPath"
|
||||
|
||||
# Build installer with iscc.exe
|
||||
|
||||
Write-Host "Building installer for Windows $arch with version $version..."
|
||||
|
||||
|
||||
$publishDir = (Resolve-Path $publishDir).Path
|
||||
$outputDir = (Resolve-Path $outputDir).Path
|
||||
$installerScript = (Resolve-Path $installerScript).Path
|
||||
@@ -221,27 +298,111 @@ jobs:
|
||||
"/DIsSelfContained=$selfContained",
|
||||
$installerScript
|
||||
)
|
||||
|
||||
|
||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
||||
|
||||
# Execute the compiler
|
||||
|
||||
& $isccPath @compileArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if build was successful
|
||||
|
||||
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $installerFile) {
|
||||
Write-Error "Failed to create installer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Successfully created: $($installerFile.Name)"
|
||||
|
||||
Write-Host "Successfully created: $($installerFile.Name)"
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- 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 = 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
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Signed FileMap Update Package
|
||||
if: matrix.self_contained == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-update-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
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:
|
||||
@@ -254,7 +415,7 @@ jobs:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
name: Build_Linux
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -270,12 +431,21 @@ jobs:
|
||||
libfontconfig1 libfreetype6 \
|
||||
libx11-6 libxrandr2 libxinerama1 \
|
||||
libxi6 libxcursor1 libxext6 \
|
||||
libxrender1 libxkbcommon-x11-0
|
||||
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 }}
|
||||
@@ -288,11 +458,35 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for Linux x64..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-linux-x64 \
|
||||
--self-contained \
|
||||
-r linux-x64 \
|
||||
-p:PublishAot=true \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
||||
ls -lh ./publish/launcher-linux-x64/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
-c Release \
|
||||
-o ./publish/linux-x64 \
|
||||
-o ./publish/linux-x64-app \
|
||||
--self-contained \
|
||||
-r linux-x64 \
|
||||
-p:PublishSingleFile=false \
|
||||
@@ -306,6 +500,34 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
publishDir="publish/linux-x64"
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
|
||||
echo "New directory structure:"
|
||||
find "$publishDir" -maxdepth 2 | head -50
|
||||
|
||||
rm -rf "$launcherDir"
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
@@ -315,28 +537,24 @@ jobs:
|
||||
arch="amd64"
|
||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||
|
||||
# Verify source directory exists
|
||||
|
||||
if [ ! -d "$source" ]; then
|
||||
echo "Error: Source directory not found: $source"
|
||||
ls -la publish/ || echo "publish directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create DEB package structure
|
||||
|
||||
mkdir -p "build-deb/DEBIAN"
|
||||
mkdir -p "build-deb/usr/local/bin"
|
||||
mkdir -p "build-deb/usr/share/applications"
|
||||
mkdir -p "build-deb/usr/share/pixmaps"
|
||||
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
|
||||
|
||||
# Copy application files
|
||||
|
||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||
|
||||
# Verify copy was successful
|
||||
|
||||
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
|
||||
echo "DEB package contains $item_count files"
|
||||
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: DEB package is empty after copy"
|
||||
exit 1
|
||||
@@ -349,7 +567,7 @@ jobs:
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
|
||||
@@ -366,8 +584,7 @@ jobs:
|
||||
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
|
||||
printf '%s\n' 'fi'
|
||||
} > "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create control file (NOTE: No leading spaces in control file)
|
||||
|
||||
{
|
||||
printf '%s\n' "Package: $package_name"
|
||||
printf '%s\n' "Version: $package_version"
|
||||
@@ -376,15 +593,13 @@ jobs:
|
||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
||||
printf '%s\n' " A desktop application for LanMountain."
|
||||
} > "build-deb/DEBIAN/control"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
|
||||
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
||||
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
chmod 755 "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create DEB file
|
||||
|
||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
||||
@@ -393,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:
|
||||
@@ -408,7 +707,7 @@ jobs:
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
name: Build_macOS_${{ matrix.arch }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -417,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 }}
|
||||
@@ -433,11 +736,35 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish
|
||||
- name: Publish Launcher (AOT)
|
||||
run: |
|
||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
||||
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||
-c Release \
|
||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||
--self-contained \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:PublishAot=true \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Launcher AOT publish failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
||||
|
||||
- name: Publish Main App
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
-c Release \
|
||||
-o ./publish/macos-${{ matrix.arch }} \
|
||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||
--self-contained \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:PublishSingleFile=false \
|
||||
@@ -451,45 +778,57 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Package as DMG
|
||||
- name: Restructure and Package as DMG
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
arch="${{ matrix.arch }}"
|
||||
source="publish/macos-$arch"
|
||||
app_name="LanMountainDesktop"
|
||||
package_name="${app_name}-${version}-macos-${arch}"
|
||||
|
||||
# Verify source directory exists
|
||||
if [ ! -d "$source" ]; then
|
||||
echo "Error: Source directory not found: $source"
|
||||
ls -la publish/ || echo "publish directory not found"
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
echo "Restructuring for Launcher mode..."
|
||||
echo "Version: $version"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
|
||||
appDir="app-$version"
|
||||
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||
|
||||
if [ -d "$appSourceDir" ]; then
|
||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||
else
|
||||
echo "Error: Main app source directory not found: $appSourceDir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create app bundle structure
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
|
||||
if [ -d "$launcherDir" ]; then
|
||||
echo "Copying Launcher to root..."
|
||||
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
# Copy application files
|
||||
cp -r "$source"/* "${app_name}.app/Contents/MacOS/"
|
||||
|
||||
# Verify copy was successful
|
||||
|
||||
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
|
||||
echo "App bundle contains $item_count files"
|
||||
|
||||
|
||||
if [ "$item_count" -eq 0 ]; then
|
||||
echo "Error: App bundle is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create Info.plist
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
printf '%s\n' '<plist version="1.0">'
|
||||
printf '%s\n' '<dict>'
|
||||
printf '%s\n' ' <key>CFBundleExecutable</key>'
|
||||
printf '%s\n' ' <string>LanMountainDesktop</string>'
|
||||
printf '%s\n' ' <string>LanMountainDesktop.Launcher</string>'
|
||||
printf '%s\n' ' <key>CFBundleName</key>'
|
||||
printf '%s\n' ' <string>LanMountain Desktop</string>'
|
||||
printf '%s\n' ' <key>CFBundleVersion</key>'
|
||||
@@ -503,11 +842,10 @@ jobs:
|
||||
printf '%s\n' '</dict>'
|
||||
printf '%s\n' '</plist>'
|
||||
} > "${app_name}.app/Contents/Info.plist"
|
||||
|
||||
# Create DMG
|
||||
|
||||
mkdir -p dmg-temp
|
||||
cp -r "${app_name}.app" dmg-temp/
|
||||
|
||||
|
||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
||||
echo "Successfully created: ${package_name}.dmg"
|
||||
ls -lh "${package_name}.dmg"
|
||||
@@ -515,8 +853,7 @@ jobs:
|
||||
echo "Error: Failed to create DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
rm -rf dmg-temp "${app_name}.app"
|
||||
|
||||
- name: Upload
|
||||
@@ -532,7 +869,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -542,32 +879,73 @@ jobs:
|
||||
|
||||
- name: List artifacts structure
|
||||
run: |
|
||||
echo "🔍 Artifact directory structure:"
|
||||
echo "Artifact directory structure:"
|
||||
find artifacts -type f -o -type d | sort
|
||||
echo ""
|
||||
echo "📊 Files found:"
|
||||
echo "Files found:"
|
||||
find artifacts -type f -exec ls -lh {} \;
|
||||
echo ""
|
||||
echo "📁 Full tree:"
|
||||
echo "Full tree:"
|
||||
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
|
||||
|
||||
- name: Flatten artifacts for release
|
||||
run: |
|
||||
echo "📦 Organizing artifacts..."
|
||||
echo "Organizing artifacts..."
|
||||
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 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"
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
echo ""
|
||||
echo "📋 Total files:"
|
||||
echo "Total files:"
|
||||
file_count=$(find release-files -type f | wc -l)
|
||||
echo "$file_count"
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "Error: No installer/package files found for release"
|
||||
echo "Error: No release files found"
|
||||
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:
|
||||
@@ -582,17 +960,26 @@ jobs:
|
||||
## Release ${{ needs.prepare.outputs.version }}
|
||||
|
||||
### Windows
|
||||
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (包含 .NET 运行时)
|
||||
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (包含 .NET 运行时)
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
||||
|
||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
### 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: Launcher will detect platform-matching signed assets and apply update on next startup.
|
||||
|
||||
### Linux
|
||||
- **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64)
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||
|
||||
### macOS
|
||||
- **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor
|
||||
- **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
||||
|
||||
See commits for changes.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -512,3 +512,5 @@ nul
|
||||
/*.deb
|
||||
/*.dmg
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
|
||||
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
@@ -0,0 +1,805 @@
|
||||
# LanMountainDesktop Launcher 全面改进计划
|
||||
|
||||
## 概述
|
||||
|
||||
本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。
|
||||
|
||||
## 目标
|
||||
|
||||
1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合
|
||||
2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序
|
||||
3. **P2 (推荐完成)**: 实现 Launcher 自更新机制
|
||||
4. **P3 (可选优化)**: 性能优化和代码清理
|
||||
5. **P4 (长期规划)**: 增强功能和可扩展性
|
||||
|
||||
## 当前问题
|
||||
|
||||
1. Launcher 是 Avalonia 应用,启动慢、内存占用高
|
||||
2. Launcher 引用了 PluginSdk,与主程序有耦合
|
||||
3. 主程序引用了 Launcher,构建关系复杂
|
||||
4. Launcher 职责过多(OOBE + Splash + 更新 + 插件 + 启动)
|
||||
5. 缺少 Launcher 自更新机制
|
||||
6. GitHub Actions 工作流需要适配新的目录结构
|
||||
|
||||
## 改进后架构
|
||||
|
||||
```
|
||||
安装根目录/
|
||||
├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码)
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe ← 主程序
|
||||
│ └── ... (所有依赖)
|
||||
└── .launcher/ ← 启动器数据(可选)
|
||||
└── snapshots/ ← 版本快照
|
||||
```
|
||||
|
||||
## 详细实施步骤
|
||||
|
||||
### P0: 基础架构重构
|
||||
|
||||
#### 1. 重写 Launcher 为极简模式
|
||||
|
||||
**文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||
|
||||
**目标**:
|
||||
- 代码量控制在 100 行以内
|
||||
- 零外部依赖(不使用 Avalonia)
|
||||
- 只负责:版本选择、启动主程序、清理旧版本
|
||||
|
||||
**完整实现代码**:
|
||||
|
||||
```csharp
|
||||
// LanMountainDesktop.Launcher/Program.cs
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const string HostExecutableName = "LanMountainDesktop.exe";
|
||||
private const string HostExecutableNameLinux = "LanMountainDesktop";
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
var rootDir = GetRootDirectory();
|
||||
|
||||
// 1. 查找最佳版本
|
||||
var installation = FindBestVersion(rootDir);
|
||||
if (installation == null)
|
||||
{
|
||||
ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 2. 清理旧版本(异步,不阻塞)
|
||||
_ = Task.Run(() => CleanupOldVersions(rootDir));
|
||||
|
||||
// 3. 启动主程序
|
||||
return LaunchHost(installation, args);
|
||||
}
|
||||
|
||||
private static string GetRootDirectory()
|
||||
{
|
||||
return Path.GetFullPath(
|
||||
Path.GetDirectoryName(Environment.ProcessPath) ?? "");
|
||||
}
|
||||
|
||||
private static string? FindBestVersion(string rootDir)
|
||||
{
|
||||
var exeName = OperatingSystem.IsWindows()
|
||||
? HostExecutableName
|
||||
: HostExecutableNameLinux;
|
||||
|
||||
return Directory.GetDirectories(rootDir)
|
||||
.Where(x => IsValidVersionDirectory(x, exeName))
|
||||
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1)
|
||||
.ThenByDescending(x => ParseVersion(Path.GetFileName(x)))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static bool IsValidVersionDirectory(string path, string exeName)
|
||||
{
|
||||
var dirName = Path.GetFileName(path);
|
||||
return dirName.StartsWith("app-") &&
|
||||
!File.Exists(Path.Combine(path, ".destroy")) &&
|
||||
!File.Exists(Path.Combine(path, ".partial")) &&
|
||||
File.Exists(Path.Combine(path, exeName));
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string dirName)
|
||||
{
|
||||
// app-1.0.0 or app-1.0.0-123
|
||||
var parts = dirName.Split('-');
|
||||
if (parts.Length >= 2 && Version.TryParse(parts[1], out var v))
|
||||
return v;
|
||||
return new Version(0, 0);
|
||||
}
|
||||
|
||||
private static void CleanupOldVersions(string rootDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var oldVersions = Directory.GetDirectories(rootDir)
|
||||
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||
|
||||
foreach (var dir in oldVersions)
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
catch { /* 忽略清理失败 */ }
|
||||
}
|
||||
|
||||
private static int LaunchHost(string installation, string[] args)
|
||||
{
|
||||
var exeName = OperatingSystem.IsWindows()
|
||||
? HostExecutableName
|
||||
: HostExecutableNameLinux;
|
||||
var exePath = Path.Combine(installation, exeName);
|
||||
|
||||
// Linux/macOS: 确保可执行权限
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
EnsureExecutable(exePath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = exePath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installation),
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
|
||||
// 传递环境变量
|
||||
startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] =
|
||||
Path.GetDirectoryName(installation);
|
||||
startInfo.EnvironmentVariables["LMD_VERSION"] =
|
||||
Path.GetFileName(installation).Replace("app-", "");
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(startInfo);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError($"启动失败: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"+x \"{path}\"",
|
||||
CreateNoWindow = true
|
||||
})?.WaitForExit();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void ShowError(string message)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Win32 MessageBox
|
||||
try
|
||||
{
|
||||
MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 修改 Launcher 项目文件
|
||||
|
||||
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||
|
||||
**完整内容**:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- 图标资源 -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
#### 3. 移除主程序对 Launcher 的引用
|
||||
|
||||
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
**修改**: 删除以下行
|
||||
```xml
|
||||
<!-- 删除这一行 -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
|
||||
```
|
||||
|
||||
#### 4. 修改主程序支持新架构
|
||||
|
||||
**文件**: `LanMountainDesktop/Program.cs`
|
||||
|
||||
**修改**: 添加环境变量读取
|
||||
|
||||
```csharp
|
||||
// 在 Program.cs 中添加
|
||||
internal static class LaunchContext
|
||||
{
|
||||
public static string? PackageRoot =>
|
||||
Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT");
|
||||
public static string? Version =>
|
||||
Environment.GetEnvironmentVariable("LMD_VERSION");
|
||||
public static bool IsLaunchedByLauncher =>
|
||||
!string.IsNullOrEmpty(PackageRoot);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P1: 功能迁移
|
||||
|
||||
#### 5. 将 OOBE 迁移到主程序
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||
|
||||
```csharp
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services.Oobe;
|
||||
|
||||
public class OobeService
|
||||
{
|
||||
private readonly string _oobeStatePath;
|
||||
|
||||
public OobeService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
_oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
return !File.Exists(_oobeStatePath);
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_oobeStatePath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.Oobe.OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="800"
|
||||
Height="600"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid>
|
||||
<!-- OOBE 界面内容 -->
|
||||
<TextBlock Text="欢迎使用阑山桌面" FontSize="24" HorizontalAlignment="Center" Margin="0,50,0,0"/>
|
||||
<Button Content="开始使用" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="OnStartClick"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
```
|
||||
|
||||
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
```csharp
|
||||
// 在 OnFrameworkInitializationCompleted 中添加
|
||||
private async Task InitializeOobeAsync()
|
||||
{
|
||||
var oobeService = new OobeService();
|
||||
if (oobeService.IsFirstRun())
|
||||
{
|
||||
var oobeWindow = new Views.Oobe.OobeWindow();
|
||||
await oobeWindow.ShowDialog();
|
||||
oobeService.MarkCompleted();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. 将 Splash 迁移到主程序
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.Splash.SplashWindow"
|
||||
Title="阑山桌面"
|
||||
Width="400"
|
||||
Height="300"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ShowInTaskbar="False"
|
||||
SystemDecorations="None">
|
||||
<Grid Background="{DynamicResource SystemAccentColor}">
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<Image Source="/Assets/logo_nightly.png" Width="100" Height="100"/>
|
||||
<TextBlock Text="阑山桌面" FontSize="20" Margin="0,20,0,0" HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="StatusText" Text="正在启动..." Margin="0,10,0,0" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
```
|
||||
|
||||
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
```csharp
|
||||
// 在初始化时显示 Splash
|
||||
private SplashWindow? _splashWindow;
|
||||
|
||||
private void ShowSplash()
|
||||
{
|
||||
_splashWindow = new SplashWindow();
|
||||
_splashWindow.Show();
|
||||
}
|
||||
|
||||
private void CloseSplash()
|
||||
{
|
||||
_splashWindow?.Close();
|
||||
_splashWindow = null;
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. 将更新逻辑迁移到主程序
|
||||
|
||||
**新建目录**: `LanMountainDesktop/Services/Update/`
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||
|
||||
```csharp
|
||||
using System.Net.Http.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public class UpdateService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _currentVersion;
|
||||
|
||||
public UpdateService()
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop");
|
||||
_currentVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(UpdateChannel channel)
|
||||
{
|
||||
// 调用 GitHub Release API
|
||||
var releases = await _httpClient.GetFromJsonAsync<List<GitHubRelease>>(
|
||||
"https://api.github.com/repos/ClassIsland/LanMountainDesktop/releases");
|
||||
|
||||
var latest = channel == UpdateChannel.Stable
|
||||
? releases?.FirstOrDefault(r => !r.Prerelease)
|
||||
: releases?.FirstOrDefault();
|
||||
|
||||
if (latest == null)
|
||||
return new UpdateCheckResult { HasUpdate = false };
|
||||
|
||||
var latestVersion = latest.TagName.TrimStart('v');
|
||||
var hasUpdate = new Version(latestVersion) > new Version(_currentVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = hasUpdate,
|
||||
Version = latestVersion,
|
||||
DownloadUrl = latest.Assets.FirstOrDefault()?.BrowserDownloadUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateCheckResult
|
||||
{
|
||||
public bool HasUpdate { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? DownloadUrl { get; set; }
|
||||
}
|
||||
|
||||
public enum UpdateChannel { Stable, Preview }
|
||||
|
||||
public class GitHubRelease
|
||||
{
|
||||
public string TagName { get; set; } = "";
|
||||
public bool Prerelease { get; set; }
|
||||
public List<GitHubAsset> Assets { get; set; } = new();
|
||||
}
|
||||
|
||||
public class GitHubAsset
|
||||
{
|
||||
public string BrowserDownloadUrl { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. 将插件管理迁移到主程序
|
||||
|
||||
**新建目录**: `LanMountainDesktop/Services/Plugins/`
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||
|
||||
```csharp
|
||||
namespace LanMountainDesktop.Services.Plugins;
|
||||
|
||||
public class PluginUpdateService
|
||||
{
|
||||
private readonly string _pluginsDirectory;
|
||||
|
||||
public PluginUpdateService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
_pluginsDirectory = Path.Combine(appData, "LanMountainDesktop", "plugins");
|
||||
}
|
||||
|
||||
public async Task CheckAndUpdatePluginsAsync()
|
||||
{
|
||||
// 检查插件更新
|
||||
// 下载并安装更新
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P2: 自更新机制
|
||||
|
||||
#### 9. 实现 Launcher 自更新
|
||||
|
||||
**修改文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||
|
||||
```csharp
|
||||
// 在 Main 方法开头添加自更新检查
|
||||
private static void CheckForLauncherUpdate()
|
||||
{
|
||||
var rootDir = GetRootDirectory();
|
||||
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||
|
||||
if (File.Exists(updatePath))
|
||||
{
|
||||
// 有新版本 Launcher,替换自身
|
||||
try
|
||||
{
|
||||
var currentPath = Environment.ProcessPath;
|
||||
var backupPath = currentPath + ".old";
|
||||
|
||||
// 重命名当前版本
|
||||
if (File.Exists(backupPath))
|
||||
File.Delete(backupPath);
|
||||
File.Move(currentPath!, backupPath);
|
||||
|
||||
// 移动新版本
|
||||
File.Move(updatePath, currentPath!);
|
||||
|
||||
// 删除备份
|
||||
File.Delete(backupPath);
|
||||
|
||||
// 重启自己
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = currentPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 回滚
|
||||
Console.Error.WriteLine($"Launcher 更新失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 10. 主程序支持更新 Launcher
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||
|
||||
```csharp
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
public class LauncherUpdateService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public LauncherUpdateService()
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateLauncherAsync(string downloadUrl)
|
||||
{
|
||||
var rootDir = LaunchContext.PackageRoot
|
||||
?? Path.GetDirectoryName(Environment.ProcessPath)!;
|
||||
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||
|
||||
// 下载新版本
|
||||
var response = await _httpClient.GetAsync(downloadUrl);
|
||||
await using var fs = File.Create(updatePath);
|
||||
await response.Content.CopyToAsync(fs);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RestartWithNewLauncher()
|
||||
{
|
||||
var launcherPath = Path.Combine(
|
||||
LaunchContext.PackageRoot ?? "",
|
||||
"LanMountainDesktop.exe");
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
|
||||
// 退出主程序,让 Launcher 接管
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P3: 清理旧代码
|
||||
|
||||
#### 11. 删除文件清单
|
||||
|
||||
**删除以下文件/目录**:
|
||||
|
||||
```
|
||||
LanMountainDesktop.Launcher/
|
||||
├── App.axaml ← 删除
|
||||
├── App.axaml.cs ← 删除
|
||||
├── Views/ ← 删除整个目录
|
||||
│ ├── OobeWindow.axaml
|
||||
│ ├── OobeWindow.axaml.cs
|
||||
│ ├── SplashWindow.axaml
|
||||
│ └── SplashWindow.axaml.cs
|
||||
├── Services/ ← 删除大部分
|
||||
│ ├── LauncherFlowCoordinator.cs ← 删除
|
||||
│ ├── OobeStateService.cs ← 删除
|
||||
│ ├── UpdateCheckService.cs ← 删除
|
||||
│ ├── UpdateEngineService.cs ← 删除
|
||||
│ ├── PluginInstallerService.cs ← 删除
|
||||
│ └── PluginUpgradeQueueService.cs ← 删除
|
||||
└── Models/ ← 删除(如不再需要)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P4: GitHub Actions 工作流修改
|
||||
|
||||
#### 12. 修改 release.yml
|
||||
|
||||
**关键修改点**:
|
||||
|
||||
1. **Launcher 单独编译**:
|
||||
```yaml
|
||||
- name: Publish Launcher
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./publish/launcher-win-x64 `
|
||||
--self-contained `
|
||||
-r win-x64 `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:PublishTrimmed=false `
|
||||
-p:DebugType=none
|
||||
```
|
||||
|
||||
2. **目录结构调整**:
|
||||
```yaml
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$publishDir = "publish/windows-x64"
|
||||
$launcherDir = "publish/launcher-win-x64"
|
||||
$appDir = "app-$version"
|
||||
|
||||
# 创建新结构
|
||||
$newStructure = "publish-launcher/windows-x64"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force
|
||||
|
||||
# 移动主程序到 app-{version}/
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
# 复制 Launcher 到根目录
|
||||
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||
|
||||
# 创建 .current 标记
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||
```
|
||||
|
||||
3. **Linux/macOS 同样调整**:
|
||||
- Linux: 修改 DEB 打包流程
|
||||
- macOS: 修改 DMG 打包流程
|
||||
|
||||
#### 13. 修改 build.yml
|
||||
|
||||
**修改**: 移除 Launcher 相关构建步骤,因为 Launcher 现在完全独立
|
||||
|
||||
---
|
||||
|
||||
### P5: 图标资源处理
|
||||
|
||||
#### 14. Launcher 图标配置
|
||||
|
||||
**方案**: 使用链接方式引用主程序图标
|
||||
|
||||
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<!-- 链接主程序的图标 -->
|
||||
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
#### 15. 安装程序配置
|
||||
|
||||
**文件**: `LanMountainDesktop/installer/LanMountainDesktop.iss` (Inno Setup)
|
||||
|
||||
**关键配置**:
|
||||
|
||||
```ini
|
||||
[Setup]
|
||||
AppName=阑山桌面
|
||||
AppVersion={#MyAppVersion}
|
||||
DefaultDirName={autopf}\LanMountainDesktop
|
||||
OutputBaseFilename=LanMountainDesktop-Setup-{#MyAppVersion}-x64
|
||||
SetupIconFile=..\Assets\logo_nightly.ico
|
||||
UninstallDisplayIcon={app}\LanMountainDesktop.exe
|
||||
|
||||
[Files]
|
||||
; Launcher
|
||||
Source: "..\..\publish\windows-x64\LanMountainDesktop.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; 主程序版本目录
|
||||
Source: "..\..\publish\windows-x64\app-{#MyAppVersion}\*"; DestDir: "{app}\app-{#MyAppVersion}"; Flags: ignoreversion recursesubdirs
|
||||
|
||||
[Icons]
|
||||
; 桌面快捷方式
|
||||
Name: "{autodesktop}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||
; 开始菜单
|
||||
Name: "{group}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 修改文件
|
||||
|
||||
1. `LanMountainDesktop.Launcher/Program.cs` - 完全重写
|
||||
2. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 简化依赖
|
||||
3. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||
4. `LanMountainDesktop/Program.cs` - 添加 LaunchContext
|
||||
5. `LanMountainDesktop/App.axaml.cs` - 添加 OOBE/Splash/更新入口
|
||||
6. `.github/workflows/release.yml` - 调整打包流程
|
||||
7. `.github/workflows/build.yml` - 适配新构建流程
|
||||
|
||||
### 新增文件
|
||||
|
||||
1. `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||
2. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||
3. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml.cs`
|
||||
4. `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||
5. `LanMountainDesktop/Views/Splash/SplashWindow.axaml.cs`
|
||||
6. `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||
7. `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||
8. `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||
|
||||
### 删除文件
|
||||
|
||||
1. `LanMountainDesktop.Launcher/App.axaml`
|
||||
2. `LanMountainDesktop.Launcher/App.axaml.cs`
|
||||
3. `LanMountainDesktop.Launcher/Views/` 目录
|
||||
4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||
5. `LanMountainDesktop.Launcher/Services/OobeStateService.cs`
|
||||
6. `LanMountainDesktop.Launcher/Services/UpdateCheckService.cs`
|
||||
7. `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
||||
8. `LanMountainDesktop.Launcher/Services/PluginInstallerService.cs`
|
||||
9. `LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs`
|
||||
|
||||
---
|
||||
|
||||
## 风险与回滚方案
|
||||
|
||||
### 风险
|
||||
|
||||
1. **启动失败**: 新 Launcher 可能有 bug 导致无法启动
|
||||
2. **更新中断**: 更新逻辑迁移可能导致更新失败
|
||||
3. **图标丢失**: 图标配置错误导致快捷方式无图标
|
||||
|
||||
### 回滚方案
|
||||
|
||||
1. 保留原 Launcher 代码分支
|
||||
2. 准备紧急修复版本
|
||||
3. 用户可手动下载完整安装包恢复
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] Launcher 能正常启动主程序
|
||||
- [ ] 版本选择逻辑正确
|
||||
- [ ] 旧版本清理正常
|
||||
- [ ] OOBE 流程正常
|
||||
- [ ] Splash 显示正常
|
||||
- [ ] 更新检查正常
|
||||
- [ ] 插件安装正常
|
||||
- [ ] GitHub Actions 打包成功
|
||||
- [ ] 安装程序图标正常
|
||||
- [ ] 快捷方式图标正常
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序建议
|
||||
|
||||
### 第一阶段(立即实施)
|
||||
1. 重写 Launcher Program.cs
|
||||
2. 修改 Launcher.csproj
|
||||
3. 移除主程序对 Launcher 的引用
|
||||
4. 测试基本启动功能
|
||||
|
||||
### 第二阶段(功能迁移)
|
||||
1. 迁移 OOBE 到主程序
|
||||
2. 迁移 Splash 到主程序
|
||||
3. 迁移更新逻辑到主程序
|
||||
4. 迁移插件管理到主程序
|
||||
|
||||
### 第三阶段(CI/CD)
|
||||
1. 修改 release.yml
|
||||
2. 修改 build.yml
|
||||
3. 测试打包流程
|
||||
4. 验证安装程序
|
||||
|
||||
### 第四阶段(优化)
|
||||
1. 实现 Launcher 自更新
|
||||
2. 性能优化
|
||||
3. 清理旧代码
|
||||
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# LanMountainDesktop Launcher 改进计划 V2
|
||||
|
||||
## 核心设计理念
|
||||
|
||||
**Launcher 是核心协调器,不是极简启动器**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Launcher 职责定位 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Launcher 负责(启动前 & 退出后): │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • OOBE 首次引导 │ │
|
||||
│ │ • 启动动画 (Splash) │ │
|
||||
│ │ • 插件安装 │ │
|
||||
│ │ • 插件更新 │ │
|
||||
│ │ • 应用增量更新安装(不是下载!) │ │
|
||||
│ │ • 应用静默更新安装 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 主程序负责(运行时): │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 多线程下载(有完整 Downloader) │ │
|
||||
│ │ • 更新渠道切换 │ │
|
||||
│ │ • 下载管理 │ │
|
||||
│ │ • 与 Launcher 通讯(启动进度) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 关键优势: │
|
||||
│ • Launcher 在应用启动前运行 → 可以安装更新而不担心文件占用 │
|
||||
│ • Launcher 在应用退出后运行 → 可以完成待处理的安装任务 │
|
||||
│ • 主程序专注下载 → 利用完整的多线程下载器提高效率 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 为什么保留 Avalonia?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 保留 Avalonia 的理由 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 启动画面 (Splash) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 需要显示启动进度 │ │
|
||||
│ │ • 需要显示品牌 Logo │ │
|
||||
│ │ • 需要流畅的动画效果 │ │
|
||||
│ │ • 纯 Win32 实现复杂且不易维护 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. OOBE 首次引导 │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 需要多步骤向导界面 │ │
|
||||
│ │ • 需要丰富的交互控件 │ │
|
||||
│ │ • 需要与主程序一致的视觉风格 │ │
|
||||
│ │ • Avalonia 提供完整的 UI 框架 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. 与主程序的技术栈一致 │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 共享主题和资源 │ │
|
||||
│ │ • 共享控件和样式 │ │
|
||||
│ │ • 便于维护和迭代 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 改进后的架构设计
|
||||
|
||||
### 目录结构(保持不变)
|
||||
|
||||
```
|
||||
安装根目录/
|
||||
├── LanMountainDesktop.exe ← Launcher(Avalonia 应用)
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe ← 主程序
|
||||
│ └── ... (所有依赖)
|
||||
└── .launcher/ ← Launcher 数据目录
|
||||
├── update/ ← 更新缓存
|
||||
│ └── incoming/ ← 下载的更新包(主程序下载到这里)
|
||||
└── snapshots/ ← 版本快照
|
||||
```
|
||||
|
||||
### 核心流程设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 启动流程(含通讯机制) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 用户启动 LanMountainDesktop.exe (Launcher) │
|
||||
│ ↓ │
|
||||
│ 2. Launcher 检查是否有待处理的更新安装 │
|
||||
│ ↓ │
|
||||
│ 3. 有更新?──Yes──▶ 显示 Splash "正在安装更新..." │
|
||||
│ ↓ ↓ │
|
||||
│ No 安装更新(增量/静默) │
|
||||
│ ↓ ↓ │
|
||||
│ 4. 检查是否首次运行 ──Yes──▶ 显示 OOBE 窗口 │
|
||||
│ ↓ No ↓ │
|
||||
│ 5. 显示 Splash "正在启动..." 完成 OOBE │
|
||||
│ ↓ │
|
||||
│ 6. 启动主程序进程(带通讯参数) │
|
||||
│ ↓ │
|
||||
│ 7. Launcher 保持运行,监听主程序进度 ─────── IPC 通讯 ───────▶ 主程序 │
|
||||
│ ↓ │
|
||||
│ 8. 主程序报告启动进度 ─────── IPC 通讯 ───────▶ Launcher 更新 Splash │
|
||||
│ ↓ │
|
||||
│ 9. 主程序完全启动 ──Yes──▶ Launcher 关闭 Splash,进入后台/退出 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 退出流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 退出流程(处理待安装任务) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 主程序准备退出 │
|
||||
│ ↓ │
|
||||
│ 2. 检查是否有待安装的更新/插件 ──Yes──▶ 重启 Launcher 并传递参数 │
|
||||
│ ↓ No ↓ │
|
||||
│ 3. 正常退出 Launcher 在应用退出后运行 │
|
||||
│ ↓ │
|
||||
│ 安装待处理的任务 │
|
||||
│ ↓ │
|
||||
│ 完成后再次启动主程序 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Launcher 与主程序的通讯机制
|
||||
|
||||
### IPC 方案选择
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ IPC 通讯方案 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 方案 1: 命令行参数 + 退出码(推荐用于启动阶段) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Launcher 启动主程序: │ │
|
||||
│ │ LanMountainDesktop.exe --launcher-pid 12345 --ipc-port 50000 │ │
|
||||
│ │ │ │
|
||||
│ │ 主程序通过命名管道/HTTP 与 Launcher 通讯 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 方案 2: 命名管道(推荐用于进度报告) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||
│ │ 主程序连接并发送进度消息 │ │
|
||||
│ │ │ │
|
||||
│ │ 消息格式: JSON │ │
|
||||
│ │ {"stage": "initializing", "progress": 30, "message": "加载设置..."} │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 方案 3: 共享内存/文件(简单状态同步) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Launcher 和主程序读写同一个状态文件 │ │
|
||||
│ │ .launcher/state/startup_status.json │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 通讯协议设计
|
||||
|
||||
```csharp
|
||||
// 共享契约(LanMountainDesktop.Shared.Contracts)
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum StartupStage
|
||||
{
|
||||
Initializing,
|
||||
LoadingSettings,
|
||||
LoadingPlugins,
|
||||
InitializingUI,
|
||||
Ready
|
||||
}
|
||||
|
||||
public record StartupProgressMessage
|
||||
{
|
||||
public StartupStage Stage { get; init; }
|
||||
public int ProgressPercent { get; init; } // 0-100
|
||||
public string? Message { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public static class LauncherIpc
|
||||
{
|
||||
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||
public const string EnvironmentVariablePrefix = "LMD_";
|
||||
}
|
||||
```
|
||||
|
||||
## 详细实施步骤
|
||||
|
||||
### P0: 架构调整(核心)
|
||||
|
||||
#### 1. 调整 Launcher 项目引用
|
||||
|
||||
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||
|
||||
**修改**:
|
||||
- 保留 Avalonia 依赖
|
||||
- 移除 PluginSdk 引用(Launcher 不需要)
|
||||
- 添加 Shared.Contracts 引用(用于 IPC)
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon>Assetsogo_nightly.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 保留 Avalonia -->
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 图标资源 -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
#### 2. 移除主程序对 Launcher 的引用
|
||||
|
||||
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
**修改**: 删除 Launcher 引用
|
||||
```xml
|
||||
<!-- 删除 -->
|
||||
<!-- <ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" /> -->
|
||||
```
|
||||
|
||||
#### 3. 创建 IPC 通讯契约
|
||||
|
||||
**新建文件**: `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs`
|
||||
|
||||
```csharp
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum StartupStage
|
||||
{
|
||||
Initializing,
|
||||
LoadingSettings,
|
||||
LoadingPlugins,
|
||||
InitializingUI,
|
||||
Ready
|
||||
}
|
||||
|
||||
public record StartupProgressMessage
|
||||
{
|
||||
public StartupStage Stage { get; init; }
|
||||
public int ProgressPercent { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public static class LauncherIpcConstants
|
||||
{
|
||||
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||
public const string VersionEnvVar = "LMD_VERSION";
|
||||
}
|
||||
```
|
||||
|
||||
### P1: Launcher 端实现
|
||||
|
||||
#### 4. 实现 IPC 服务端
|
||||
|
||||
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||
|
||||
```csharp
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
public class LauncherIpcServer : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _pipeServer;
|
||||
private readonly Action<StartupProgressMessage> _onProgress;
|
||||
|
||||
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||
{
|
||||
_onProgress = onProgress;
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipeServer = new NamedPipeServerStream(
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Message);
|
||||
|
||||
await _pipeServer.WaitForConnectionAsync(_cts.Token);
|
||||
|
||||
using var reader = new StreamReader(_pipeServer);
|
||||
var json = await reader.ReadToEndAsync(_cts.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||
if (message != null)
|
||||
{
|
||||
_onProgress(message);
|
||||
}
|
||||
}
|
||||
|
||||
_pipeServer.Disconnect();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"IPC error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_pipeServer?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 修改 Launcher 启动流程
|
||||
|
||||
**修改文件**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||
|
||||
```csharp
|
||||
public async Task<LauncherResult> RunAsync()
|
||||
{
|
||||
// 1. 清理旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
|
||||
// 2. 检查并安装待处理的更新(主程序下载的)
|
||||
var pendingUpdate = _updateEngine.CheckPendingUpdate();
|
||||
if (pendingUpdate.HasUpdate)
|
||||
{
|
||||
_splashWindow?.UpdateStatus("正在安装更新...");
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查并安装待处理的插件更新
|
||||
var pendingPlugins = _pluginUpgradeQueueService.CheckPendingUpgrades();
|
||||
if (pendingPlugins.HasUpgrades)
|
||||
{
|
||||
_splashWindow?.UpdateStatus("正在更新插件...");
|
||||
var pluginResult = _pluginUpgradeQueueService.ApplyPendingUpgrades();
|
||||
if (!pluginResult.Success)
|
||||
{
|
||||
return pluginResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. OOBE
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
_splashWindow?.Hide();
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None);
|
||||
}
|
||||
_splashWindow?.Show();
|
||||
}
|
||||
|
||||
// 5. 启动 IPC 服务端监听主程序进度
|
||||
using var ipcServer = new LauncherIpcServer(msg =>
|
||||
{
|
||||
_splashWindow?.UpdateProgress(msg.ProgressPercent, msg.Message);
|
||||
});
|
||||
_ = ipcServer.StartAsync();
|
||||
|
||||
// 6. 启动主程序
|
||||
_splashWindow?.UpdateStatus("正在启动...");
|
||||
var hostResult = LaunchHostWithIpc();
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
// 7. 等待主程序报告就绪或超时
|
||||
await WaitForHostReadyOrTimeoutAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
return new LauncherResult { Success = true };
|
||||
}
|
||||
```
|
||||
|
||||
### P2: 主程序端实现
|
||||
|
||||
#### 6. 实现 IPC 客户端
|
||||
|
||||
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||
|
||||
```csharp
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_pipeClient = new NamedPipeClientStream(
|
||||
".",
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.Out);
|
||||
|
||||
await _pipeClient.ConnectAsync(5000, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (_pipeClient?.IsConnected != true)
|
||||
return;
|
||||
|
||||
var json = JsonSerializer.Serialize(message);
|
||||
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
|
||||
await writer.WriteAsync(json);
|
||||
await writer.FlushAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_pipeClient?.Dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. 主程序启动时报告进度
|
||||
|
||||
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
```csharp
|
||||
public override async void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
// 检查是否从 Launcher 启动
|
||||
var launcherPid = Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar);
|
||||
if (!string.IsNullOrEmpty(launcherPid))
|
||||
{
|
||||
// 连接到 Launcher 的 IPC 服务端
|
||||
_launcherIpc = new LauncherIpcClient();
|
||||
await _launcherIpc.ConnectAsync();
|
||||
|
||||
// 报告启动进度
|
||||
await _launcherIpc.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Initializing,
|
||||
ProgressPercent = 10,
|
||||
Message = "正在初始化..."
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化设置
|
||||
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.LoadingSettings,
|
||||
ProgressPercent = 30,
|
||||
Message = "正在加载设置..."
|
||||
});
|
||||
InitializeSettings();
|
||||
|
||||
// 加载插件
|
||||
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.LoadingPlugins,
|
||||
ProgressPercent = 50,
|
||||
Message = "正在加载插件..."
|
||||
});
|
||||
await InitializePluginsAsync();
|
||||
|
||||
// 初始化 UI
|
||||
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.InitializingUI,
|
||||
ProgressPercent = 80,
|
||||
Message = "正在初始化界面..."
|
||||
});
|
||||
InitializeUI();
|
||||
|
||||
// 就绪
|
||||
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Ready,
|
||||
ProgressPercent = 100,
|
||||
Message = "就绪"
|
||||
});
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
```
|
||||
|
||||
### P3: 更新流程整合
|
||||
|
||||
#### 8. 主程序下载更新
|
||||
|
||||
**主程序职责**:
|
||||
```csharp
|
||||
// 主程序中的更新服务
|
||||
public class AppUpdateService
|
||||
{
|
||||
public async Task DownloadUpdateAsync(string version, string downloadUrl)
|
||||
{
|
||||
// 使用多线程下载器下载更新包
|
||||
var downloader = new MultiThreadedDownloader();
|
||||
var targetPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
".launcher",
|
||||
"update",
|
||||
"incoming",
|
||||
$"update-{version}.zip");
|
||||
|
||||
await downloader.DownloadAsync(downloadUrl, targetPath);
|
||||
|
||||
// 标记为待安装
|
||||
File.WriteAllText(
|
||||
Path.Combine(Path.GetDirectoryName(targetPath)!, ".pending"),
|
||||
version);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 9. Launcher 安装更新
|
||||
|
||||
**Launcher 职责**:
|
||||
```csharp
|
||||
// Launcher 中的更新安装服务
|
||||
public class UpdateInstallationService
|
||||
{
|
||||
public async Task<InstallResult> InstallPendingUpdateAsync()
|
||||
{
|
||||
var pendingPath = Path.Combine(
|
||||
_appRoot,
|
||||
".launcher",
|
||||
"update",
|
||||
"incoming",
|
||||
".pending");
|
||||
|
||||
if (!File.Exists(pendingPath))
|
||||
return InstallResult.NoUpdate;
|
||||
|
||||
var version = File.ReadAllText(pendingPath);
|
||||
var updatePackagePath = Path.Combine(
|
||||
Path.GetDirectoryName(pendingPath)!,
|
||||
$"update-{version}.zip");
|
||||
|
||||
// 创建新版本目录
|
||||
var newVersionDir = Path.Combine(_appRoot, $"app-{version}");
|
||||
Directory.CreateDirectory(newVersionDir);
|
||||
File.WriteAllText(Path.Combine(newVersionDir, ".partial"), "");
|
||||
|
||||
// 解压更新包
|
||||
ZipFile.ExtractToDirectory(updatePackagePath, newVersionDir);
|
||||
|
||||
// 验证文件完整性
|
||||
// ...
|
||||
|
||||
// 切换版本标记
|
||||
var currentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (currentDir != null)
|
||||
{
|
||||
File.Delete(Path.Combine(currentDir, ".current"));
|
||||
File.WriteAllText(Path.Combine(currentDir, ".destroy"), "");
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(newVersionDir, ".current"), "");
|
||||
File.Delete(Path.Combine(newVersionDir, ".partial"));
|
||||
|
||||
// 清理待安装标记
|
||||
File.Delete(pendingPath);
|
||||
File.Delete(updatePackagePath);
|
||||
|
||||
return InstallResult.Success;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### P4: GitHub Actions 工作流
|
||||
|
||||
#### 10. 修改 release.yml
|
||||
|
||||
**关键修改点**:
|
||||
|
||||
```yaml
|
||||
# 1. Launcher 单独编译(保留 Avalonia)
|
||||
- name: Publish Launcher
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||
-c Release `
|
||||
-o ./publish/launcher-win-x64 `
|
||||
--self-contained `
|
||||
-r win-x64 `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:DebugType=none
|
||||
|
||||
# 2. 目录结构调整
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$publishDir = "publish/windows-x64"
|
||||
$launcherDir = "publish/launcher-win-x64"
|
||||
$appDir = "app-$version"
|
||||
|
||||
# 创建新结构
|
||||
$newStructure = "publish-launcher/windows-x64"
|
||||
New-Item -ItemType Directory -Path $newStructure -Force
|
||||
|
||||
# 移动主程序到 app-{version}/
|
||||
$appPath = Join-Path $newStructure $appDir
|
||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||
|
||||
# 复制 Launcher 到根目录
|
||||
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||
|
||||
# 创建 .current 标记
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||
```
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
### 修改文件
|
||||
|
||||
1. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 调整引用
|
||||
2. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||
3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` - 添加 IPC 和更新安装
|
||||
4. `LanMountainDesktop/App.axaml.cs` - 添加 IPC 客户端和进度报告
|
||||
5. `.github/workflows/release.yml` - 调整打包流程
|
||||
|
||||
### 新增文件
|
||||
|
||||
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
||||
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
|
||||
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
|
||||
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
||||
|
||||
### 删除文件
|
||||
|
||||
1. 主程序对 Launcher 的项目引用(已存在)
|
||||
|
||||
## 实施顺序
|
||||
|
||||
### 第一阶段:基础架构
|
||||
1. 创建 IPC 契约(Shared.Contracts)
|
||||
2. 调整 Launcher 项目引用
|
||||
3. 移除主程序对 Launcher 的引用
|
||||
4. 测试基本启动
|
||||
|
||||
### 第二阶段:IPC 实现
|
||||
1. 实现 Launcher IPC 服务端
|
||||
2. 实现主程序 IPC 客户端
|
||||
3. 测试进度报告
|
||||
|
||||
### 第三阶段:更新流程
|
||||
1. 主程序实现下载功能
|
||||
2. Launcher 实现安装功能
|
||||
3. 测试完整更新流程
|
||||
|
||||
### 第四阶段:CI/CD
|
||||
1. 修改 GitHub Actions
|
||||
2. 测试打包流程
|
||||
3. 验证安装程序
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] Launcher 能正常启动主程序
|
||||
- [ ] Launcher 显示 Splash 并接收进度更新
|
||||
- [ ] 主程序能向 Launcher 报告启动进度
|
||||
- [ ] 主程序能下载更新
|
||||
- [ ] Launcher 能安装待处理的更新
|
||||
- [ ] OOBE 流程正常
|
||||
- [ ] 插件更新流程正常
|
||||
- [ ] GitHub Actions 打包成功
|
||||
- [ ] 安装程序图标正常
|
||||
- [ ] 快捷方式图标正常
|
||||
8
.trae/specs/launcher-upgrade/checklist.md
Normal file
8
.trae/specs/launcher-upgrade/checklist.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Launcher Upgrade Checklist
|
||||
|
||||
- [x] Build passes for `LanMountainDesktop.Launcher`.
|
||||
- [x] `update check` command returns structured JSON result.
|
||||
- [x] `plugin update` command returns structured JSON result.
|
||||
- [x] Legacy plugin install arguments still execute.
|
||||
- [x] OOBE and splash are implemented as separate windows.
|
||||
- [x] Update and rollback logic use version directory markers.
|
||||
54
.trae/specs/launcher-upgrade/spec.md
Normal file
54
.trae/specs/launcher-upgrade/spec.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Launcher Upgrade Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
|
||||
|
||||
- OOBE first-run entry
|
||||
- startup splash window
|
||||
- silent/incremental/rollback update
|
||||
- plugin install/update
|
||||
|
||||
## Scope (Phase 1)
|
||||
|
||||
- Avalonia GUI launcher with two windows:
|
||||
- `OOBEWindow` (first run only)
|
||||
- `SplashWindow` (every launch)
|
||||
- Default command `launch`
|
||||
- CLI commands:
|
||||
- `update check|download|apply|rollback`
|
||||
- `plugin install|update`
|
||||
- Legacy compatibility:
|
||||
- `--source --plugins-dir --result` still works for plugin install
|
||||
|
||||
## Update Behavior
|
||||
|
||||
- ClassIsland-style deployment folders:
|
||||
- `app-<version>-<number>/`
|
||||
- marker files `.current`, `.partial`, `.destroy`
|
||||
- Signed file map:
|
||||
- `files.json`
|
||||
- `files.json.sig`
|
||||
- `public-key.pem`
|
||||
- Incremental update:
|
||||
- `replace` from archive
|
||||
- `reuse` from current deployment
|
||||
- `delete` skip file in target deployment
|
||||
- Rollback:
|
||||
- snapshot metadata is written before apply
|
||||
- automatic rollback on apply failure
|
||||
- manual rollback via command
|
||||
|
||||
## OOBE and Splash
|
||||
|
||||
- OOBE is independent from splash.
|
||||
- OOBE shows only:
|
||||
- welcome text: `欢迎使用阑山桌面`
|
||||
- arrow button for continue
|
||||
- Splash shows only:
|
||||
- app name: `阑山桌面`
|
||||
|
||||
## Extensibility
|
||||
|
||||
- `IOobeStep` for future multi-step OOBE
|
||||
- `ISplashStageReporter` for future startup progress visualization
|
||||
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
12
.trae/specs/launcher-upgrade/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Launcher Upgrade Tasks
|
||||
|
||||
- [x] Convert `LanMountainDesktop.Launcher` to Avalonia launcher entry.
|
||||
- [x] Add OOBE window with first-run marker handling.
|
||||
- [x] Add splash window for every startup.
|
||||
- [x] Implement unified command parsing with default `launch`.
|
||||
- [x] Keep legacy plugin install args compatibility.
|
||||
- [x] Add plugin pending upgrade queue processing.
|
||||
- [x] Implement incremental update apply with signed file map.
|
||||
- [x] Implement snapshot-based rollback and manual rollback command.
|
||||
- [x] Add update check/download/apply/rollback CLI commands.
|
||||
- [x] Add launcher spec files under `.trae/specs/launcher-upgrade/`.
|
||||
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `release.yml` produces signed FileMap incremental assets for Windows x64/x86 and Linux x64.
|
||||
- [x] `release.yml` no longer depends on `vpk`/VeloPack packaging.
|
||||
- [x] Launcher update engine applies only signed FileMap payload path.
|
||||
- [x] Host update workflow no longer expects `releases.win.json`/`*.nupkg`.
|
||||
- [x] Update source setting includes `pdc` and preserves GitHub fallback behavior.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||
- [ ] Rollback verification report attached.
|
||||
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# PDC Incremental Update Migration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace VeloPack-based incremental packaging with a unified signed FileMap pipeline and prepare for PDC/S3 distribution compatibility, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||
|
||||
## Stage 1 (Completed in this round)
|
||||
|
||||
- Release workflow outputs signed FileMap incremental assets as the primary path:
|
||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||
- Launcher and host update runtime remove VeloPack branches and return to signed FileMap apply path.
|
||||
- Host update asset discovery supports platform-scoped names with fallback to legacy generic names.
|
||||
- Optional S3 sync publishes incremental assets in parallel with GitHub Release assets.
|
||||
|
||||
## Stage 2 (In Progress)
|
||||
|
||||
- Introduce PDC-compatible update source (`pdc`) with fallback to GitHub.
|
||||
- Add PDC metadata/latest/distribution API consumption abstraction.
|
||||
- Keep Launcher install/apply/rollback state machine unchanged.
|
||||
- Prepare `phainon.yml`-compatible release metadata for future PDCC integration.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` no longer contains VeloPack packaging steps.
|
||||
- Windows x64/x86 and Linux x64 release jobs all upload signed FileMap incremental assets.
|
||||
- Host auto-update can detect and download platform-matching signed FileMap assets.
|
||||
- Launcher `update apply` succeeds with signed FileMap payload and rollback behavior remains unchanged.
|
||||
- Optional S3 upload step works when S3 secrets/vars are configured.
|
||||
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Promote signed FileMap generation to release primary path.
|
||||
- [x] Output platform-scoped incremental assets for Windows x64/x86 and Linux x64.
|
||||
- [x] Remove launcher/runtime VeloPack branches.
|
||||
- [x] Update host asset discovery to platform-scoped signed FileMap naming.
|
||||
- [x] Add optional S3 sync for incremental assets.
|
||||
- [x] Extend update source values with `pdc`.
|
||||
- [x] Add PDC check fallback service skeleton in settings domain.
|
||||
- [ ] Add full PDC FileMap object-hash download/deploy path.
|
||||
- [ ] Add PDCC publish integration and `phainon.yml` CI publishing flow.
|
||||
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
5
.trae/specs/velopack-update-integration/checklist.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Checklist (Deprecated)
|
||||
|
||||
- [x] Spec marked as deprecated.
|
||||
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||
- [x] No release workflow dependency remains on VeloPack.
|
||||
15
.trae/specs/velopack-update-integration/spec.md
Normal file
15
.trae/specs/velopack-update-integration/spec.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# VeloPack Update Integration (Deprecated)
|
||||
|
||||
## Status
|
||||
|
||||
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||
|
||||
## Deprecation Reason
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
|
||||
## Migration Note
|
||||
|
||||
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.
|
||||
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
6
.trae/specs/velopack-update-integration/tasks.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Tasks (Deprecated)
|
||||
|
||||
- [x] Mark VeloPack integration spec as deprecated.
|
||||
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||
- [x] Remove VeloPack release workflow packaging steps.
|
||||
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,5 +1,30 @@
|
||||
# 更新日志 / Changelog
|
||||
|
||||
## [0.8.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.4) - 2026-04-12
|
||||
|
||||
### 新增 (Added)
|
||||
|
||||
- ✨ **全新淡入淡出动画系统**: 引入了一套全新的淡入淡出动画效果
|
||||
- 提升界面切换和元素显示的视觉流畅度
|
||||
- 为用户带来更加自然优雅的交互体验
|
||||
|
||||
### 变更 (Changed)
|
||||
|
||||
- ♻️ **SDK 更新**: 更新插件 SDK,优化插件开发接口和兼容性
|
||||
- 🎨 **网速显示组件优化**: 优化了网速显示组件的显示效果
|
||||
- 改进数据展示方式,提升可读性
|
||||
- 优化视觉样式,与整体设计语言更加协调
|
||||
|
||||
### 修复 (Fixed)
|
||||
|
||||
- 无
|
||||
|
||||
### 移除 (Removed)
|
||||
|
||||
- 无
|
||||
|
||||
***
|
||||
|
||||
## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12
|
||||
|
||||
### 新增 (Added)
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
9
LanMountainDesktop.Launcher/App.axaml
Normal file
9
LanMountainDesktop.Launcher/App.axaml
Normal file
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sty="using:FluentAvalonia.Styling"
|
||||
x:Class="LanMountainDesktop.Launcher.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
392
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
392
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
// 初始化日志记录器
|
||||
Logger.Initialize();
|
||||
Logger.Info("Launcher starting...");
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
|
||||
// 调试模式:显示开发调试窗口
|
||||
if (context.IsDebugMode)
|
||||
{
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
|
||||
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理各界面的预览命令
|
||||
if (HandlePreviewCommand(context, desktop))
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// apply-update 模式:显示 UpdateWindow,执行增量更新 + 插件升级
|
||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 先显示窗口,再启动后台任务
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.Show();
|
||||
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 先显示 Splash 窗口,确保应用程序不会立即退出
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.Show();
|
||||
|
||||
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理界面预览命令
|
||||
/// </summary>
|
||||
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var command = context.Command.ToLowerInvariant();
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "preview-splash":
|
||||
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
|
||||
var splashWindow = new SplashWindow();
|
||||
splashWindow.SetDebugMode(true);
|
||||
splashWindow.Show();
|
||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||
return true;
|
||||
|
||||
case "preview-error":
|
||||
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
|
||||
var errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
|
||||
errorWindow.Show();
|
||||
_ = WaitForWindowCloseAsync(desktop, errorWindow);
|
||||
return true;
|
||||
|
||||
case "preview-update":
|
||||
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
|
||||
var updateWindow = new UpdateWindow();
|
||||
updateWindow.SetDebugMode(true);
|
||||
updateWindow.Show();
|
||||
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
|
||||
return true;
|
||||
|
||||
case "preview-oobe":
|
||||
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
|
||||
return true;
|
||||
|
||||
case "preview-debug":
|
||||
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Splash 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
{
|
||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||
var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
|
||||
var reporter = (ISplashStageReporter)window;
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
reporter.Report(stages[i], messages[i]);
|
||||
await Task.Delay(800);
|
||||
}
|
||||
|
||||
// 等待5秒后自动关闭
|
||||
await Task.Delay(5000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 Update 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
|
||||
{
|
||||
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
|
||||
await Task.Delay(600);
|
||||
}
|
||||
|
||||
window.ReportComplete(true, null);
|
||||
|
||||
// 等待3秒后自动关闭
|
||||
await Task.Delay(3000);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
|
||||
string GetStageName(string stage) => stage switch
|
||||
{
|
||||
"verify" => "验证",
|
||||
"extract" => "解压",
|
||||
"apply" => "应用",
|
||||
"plugins" => "升级插件",
|
||||
"cleanup" => "清理",
|
||||
_ => stage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 OOBE 窗口预览
|
||||
/// </summary>
|
||||
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 等待用户点击开始按钮
|
||||
await window.WaitForEnterAsync();
|
||||
Console.WriteLine("[Launcher] OOBE preview completed by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
|
||||
}
|
||||
|
||||
// 用户点击后关闭应用程序
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待窗口关闭
|
||||
/// </summary>
|
||||
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
window.Closed += (s, e) => tcs.TrySetResult();
|
||||
await tcs.Task;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||
}
|
||||
|
||||
private static async Task RunCoordinatorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
ErrorWindow? errorWindow = null;
|
||||
LauncherFlowCoordinator? coordinator = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 在 try-catch 块中实例化所有服务,确保异常被捕获
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常并显示错误窗口
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"启动器发生错误: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
|
||||
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
|
||||
|
||||
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 安全关闭 Splash 窗口
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception closeEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
|
||||
}
|
||||
|
||||
// 创建并显示错误窗口
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整,或尝试重新安装。");
|
||||
errorWindow.Show();
|
||||
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
|
||||
}
|
||||
catch (Exception windowEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// 如果错误窗口成功显示,等待它关闭
|
||||
if (errorWindow != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 等待用户选择或窗口关闭
|
||||
var errorResult = await errorWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
|
||||
}
|
||||
catch (Exception waitEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
|
||||
// 如果等待失败,至少给用户5秒时间看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 错误窗口未能显示,等待5秒让用户看到控制台输出
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
catch (Exception uiEx)
|
||||
{
|
||||
// 最后的兜底:记录到控制台
|
||||
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
}
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
|
||||
/// </summary>
|
||||
private static async Task RunApplyUpdateWithWindowAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
var success = true;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 应用增量更新
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
|
||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = updateResult.Message;
|
||||
}
|
||||
|
||||
// 2. 应用待处理的插件升级
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir")
|
||||
?? Path.Combine(appRoot, "plugins");
|
||||
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success && queueResult.Code != "noop")
|
||||
{
|
||||
// 插件升级失败不阻断整体流程,仅记录到控制台
|
||||
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理旧版本,保留至少3个版本以支持回滚
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
|
||||
// 显示完成状态,短暂停留后关闭
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
||||
|
||||
if (success)
|
||||
{
|
||||
// 成功:停留 1.5 秒让用户看到"更新完成"
|
||||
await Task.Delay(1500);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 失败:停留 5 秒让用户看到错误信息
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "apply-update",
|
||||
Code = success ? "ok" : "failed",
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
23
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
23
LanMountainDesktop.Launcher/AppJsonContext.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherResult))]
|
||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
BIN
LanMountainDesktop.Launcher/Assets/logo.ico
Normal file
BIN
LanMountainDesktop.Launcher/Assets/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
11
LanMountainDesktop.Launcher/Assets/public-key.pem
Normal file
11
LanMountainDesktop.Launcher/Assets/public-key.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN 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-----
|
||||
93
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
93
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
public string Command { get; }
|
||||
|
||||
public string SubCommand { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始命令行参数,用于转发给主程序
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RawArgs { get; }
|
||||
|
||||
public bool IsLegacyPluginInstall =>
|
||||
Options.ContainsKey("source") &&
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
/// </summary>
|
||||
public bool IsDebugMode =>
|
||||
Options.ContainsKey("debug") ||
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
|
||||
{
|
||||
Command = command;
|
||||
SubCommand = subCommand;
|
||||
Options = options;
|
||||
RawArgs = rawArgs;
|
||||
}
|
||||
|
||||
public static CommandContext FromArgs(string[] args)
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[0]
|
||||
: "launch";
|
||||
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[1]
|
||||
: string.Empty;
|
||||
|
||||
return new CommandContext(command, subCommand, options, args);
|
||||
}
|
||||
|
||||
public string? GetOption(string key)
|
||||
{
|
||||
return Options.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public int GetIntOption(string key, int fallback)
|
||||
{
|
||||
var raw = GetOption(key);
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = "true";
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- AOT 发布配置文件 -->
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<!-- 启用 Native AOT -->
|
||||
<PublishAot>true</PublishAot>
|
||||
|
||||
<!-- 启用修剪以减小体积 -->
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>partial</TrimMode>
|
||||
|
||||
<!-- 自包含(不依赖系统 .NET Runtime) -->
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
<!-- 单文件发布 -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
<!-- 包含 native 库到单文件中 -->
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
|
||||
<!-- 压缩单文件 -->
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
|
||||
<!-- 优化大小 -->
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
|
||||
<!-- 禁用 ReadyToRun(AOT 不需要) -->
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
|
||||
<!-- 注意:RuntimeIdentifier 由 CI/CD 工作流通过 -r 参数传入,不在此处硬编码 -->
|
||||
<!-- 支持的平台:win-x64, win-x86, linux-x64, osx-x64, osx-arm64 -->
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- AOT 兼容性设置 -->
|
||||
<PropertyGroup>
|
||||
<!-- 允许不安全代码(某些 AOT 场景需要) -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
<!-- 启用编译时绑定(Avalonia 需要) -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- AOT 修剪配置 -->
|
||||
<ItemGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<!-- 保留 Avalonia 必要的类型 -->
|
||||
<TrimmerRootAssembly Include="Avalonia" />
|
||||
<TrimmerRootAssembly Include="Avalonia.Desktop" />
|
||||
|
||||
<!-- 保留动态序列化类型 -->
|
||||
<TrimmerRootAssembly Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<!-- 忽略某些警告 -->
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
<!-- 允许 IL 警告 -->
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
|
||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- 单文件发布配置文件(非 AOT,但接近单文件体验) -->
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(PublishSingleFileMode)' == 'true'">
|
||||
<!-- 自包含 -->
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
<!-- 单文件发布 -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
<!-- 包含 native 库到单文件中 -->
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
|
||||
<!-- 压缩单文件 -->
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
|
||||
<!-- ReadyToRun 预编译(提升启动速度) -->
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
|
||||
<!-- 修剪以减小体积 -->
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>partial</TrimMode>
|
||||
|
||||
<!-- 优化大小 -->
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
|
||||
<!-- 目标运行时 -->
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
|
||||
<!-- 导入 AOT 配置 -->
|
||||
<Import Project="LanMountainDesktop.Launcher.AOT.props" Condition="Exists('LanMountainDesktop.Launcher.AOT.props')" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- 应用程序图标 -->
|
||||
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 资源文件 -->
|
||||
<ItemGroup>
|
||||
<!-- 公钥文件 -->
|
||||
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- Avalonia 资源文件 -->
|
||||
<AvaloniaResource Include="Assets\logo.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class LauncherRuntimeContext
|
||||
{
|
||||
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
|
||||
}
|
||||
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class LauncherResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public string Stage { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = "ok";
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentVersion")]
|
||||
public string? CurrentVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("rolledBackTo")]
|
||||
public string? RolledBackTo { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, string> Details { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("installedPackagePath")]
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestName")]
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub Release 信息
|
||||
/// </summary>
|
||||
public sealed class ReleaseInfo
|
||||
{
|
||||
public required string TagName { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required bool Prerelease { get; init; }
|
||||
public required DateTime PublishedAt { get; init; }
|
||||
public required List<ReleaseAsset> Assets { get; init; }
|
||||
public string? Body { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release 资源文件
|
||||
/// </summary>
|
||||
public sealed class ReleaseAsset
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string BrowserDownloadUrl { get; init; }
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新频道
|
||||
/// </summary>
|
||||
public enum UpdateChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 正式版 - 只检查 prerelease=false 的版本
|
||||
/// </summary>
|
||||
Stable,
|
||||
|
||||
/// <summary>
|
||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||
/// </summary>
|
||||
Preview
|
||||
}
|
||||
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查结果
|
||||
/// </summary>
|
||||
public sealed class UpdateCheckResult
|
||||
{
|
||||
public bool HasUpdate { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? CurrentVersion { get; init; }
|
||||
public ReleaseInfo? Release { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class SignedFileMap
|
||||
{
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class UpdateFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string Action { get; set; } = "replace";
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotMetadata
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceVersion { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetVersion { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public string SourceDirectory { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetDirectory { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
internal sealed class UpdateApplyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string? FromVersion { get; init; }
|
||||
|
||||
public string? ToVersion { get; init; }
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
48
LanMountainDesktop.Launcher/Program.cs
Normal file
48
LanMountainDesktop.Launcher/Program.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
|
||||
// 处理遗留插件安装命令
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
{
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
|
||||
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Launcher (Launch Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Plugin Install)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
193
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal static class Commands
|
||||
{
|
||||
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin.install",
|
||||
Code = "failed",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||
{
|
||||
var appRoot = ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||
CommandContext context,
|
||||
UpdateEngineService updateEngine,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
{
|
||||
case "update":
|
||||
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
||||
case "plugin":
|
||||
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "unsupported_command",
|
||||
Message = $"Unsupported command '{context.Command}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||
{
|
||||
return context.SubCommand.ToLowerInvariant() switch
|
||||
{
|
||||
"check" => updateEngine.CheckPendingUpdate(),
|
||||
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
|
||||
"rollback" => updateEngine.RollbackLatest(),
|
||||
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
|
||||
_ => new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported update sub-command '{context.SubCommand}'."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.SubCommand.ToLowerInvariant())
|
||||
{
|
||||
case "install":
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(resultPath);
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
|
||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string ResolveAppRoot(CommandContext context)
|
||||
{
|
||||
var configured = context.GetOption("app-root");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
|
||||
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
||||
// 检查当前目录是否有 app-* 子目录(发布版)
|
||||
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
|
||||
if (appDirs.Length > 0)
|
||||
{
|
||||
// 找到 app-* 目录,说明是发布版结构
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
// 开发环境:检查父目录是否有主程序
|
||||
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
||||
var parentHost = OperatingSystem.IsWindows()
|
||||
? Path.Combine(parent, "LanMountainDesktop.exe")
|
||||
: Path.Combine(parent, "LanMountainDesktop");
|
||||
if (File.Exists(parentHost))
|
||||
{
|
||||
return parent;
|
||||
}
|
||||
|
||||
// 默认返回 baseDir
|
||||
return baseDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
||||
{
|
||||
private ISplashStageReporter? _inner;
|
||||
private readonly List<(string Stage, string Message)> _pending = [];
|
||||
|
||||
public void SetInner(ISplashStageReporter inner)
|
||||
{
|
||||
_inner = inner;
|
||||
foreach (var (stage, message) in _pending)
|
||||
{
|
||||
_inner.Report(stage, message);
|
||||
}
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
if (_inner is not null)
|
||||
{
|
||||
_inner.Report(stage, message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pending.Add((stage, message));
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
if (_inner is not null)
|
||||
{
|
||||
_inner.ReportStage(stage, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
468
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DeploymentLocator
|
||||
{
|
||||
private readonly string _appRoot;
|
||||
|
||||
public DeploymentLocator(string appRoot)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
}
|
||||
|
||||
public string GetAppRoot() => _appRoot;
|
||||
|
||||
public string? FindCurrentDeploymentDirectory()
|
||||
{
|
||||
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
|
||||
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
|
||||
return null;
|
||||
}
|
||||
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
try
|
||||
{
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
|
||||
|
||||
// ClassIsland 风格的查询:先筛选,后排序
|
||||
var validInstallations = candidates
|
||||
.Where(path =>
|
||||
{
|
||||
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
|
||||
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
|
||||
var hasExe = File.Exists(Path.Combine(path, executable));
|
||||
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
|
||||
var version = ParseVersionFromDirectory(path);
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
|
||||
$"Version={version} | " +
|
||||
$"Current={hasCurrent} | " +
|
||||
$"Destroy={hasDestroy} | " +
|
||||
$"Partial={hasPartial} | " +
|
||||
$"HasExe={hasExe}");
|
||||
|
||||
return !hasDestroy && !hasPartial && hasExe;
|
||||
})
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的排前面
|
||||
.ThenByDescending(x => x.Version) // 然后按版本号降序
|
||||
.ToList();
|
||||
|
||||
if (validInstallations.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var best = validInstallations[0];
|
||||
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
|
||||
return best.Path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
// 使用新的灵活定位器
|
||||
var options = new HostDiscoveryOptions
|
||||
{
|
||||
ExecutableName = "LanMountainDesktop",
|
||||
PreferDevModeConfig = true,
|
||||
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
|
||||
AdditionalSearchPaths = new List<string>
|
||||
{
|
||||
// 可以通过配置文件或环境变量添加更多路径
|
||||
"${AppRoot}",
|
||||
"${AppRoot}/..",
|
||||
"${BaseDirectory}/../..",
|
||||
}
|
||||
};
|
||||
|
||||
var locator = new FlexibleHostLocator(_appRoot, options);
|
||||
var result = locator.ResolveHostExecutablePath();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑(作为备选)
|
||||
return ResolveHostExecutablePathLegacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传统的主程序路径解析(作为备选)
|
||||
/// </summary>
|
||||
private string? ResolveHostExecutablePathLegacy()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
|
||||
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
var inDeployment = Path.Combine(currentDeployment, executable);
|
||||
if (File.Exists(inDeployment))
|
||||
{
|
||||
return inDeployment;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
if (File.Exists(inParent))
|
||||
{
|
||||
return inParent;
|
||||
}
|
||||
|
||||
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
// 4.1 首先检查保存的自定义路径
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
{
|
||||
return savedCustomPath;
|
||||
}
|
||||
|
||||
// 4.2 扫描开发路径
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 开发模式:查找主程序项目的输出目录
|
||||
var devPaths = GetDevelopmentPaths(executable);
|
||||
foreach (var devPath in devPaths)
|
||||
{
|
||||
if (File.Exists(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径(开发模式)
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从解决方案根目录运行
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 目录
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取开发环境可能的主程序路径
|
||||
/// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 可能的开发目录结构
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从解决方案根目录运行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 从 dev-test 目录运行
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||
}
|
||||
|
||||
public string GetCurrentVersion()
|
||||
{
|
||||
var deployment = FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(deployment))
|
||||
{
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
|
||||
}
|
||||
|
||||
public string BuildNextDeploymentDirectory(string targetVersion)
|
||||
{
|
||||
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理旧版本部署,保留最近的N个版本
|
||||
/// </summary>
|
||||
/// <param name="minVersionsToKeep">最少保留版本数,默认3个</param>
|
||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
|
||||
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
// 过滤掉无效部署目录(排除partial),按版本排序
|
||||
var validDeployments = candidates
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path),
|
||||
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
|
||||
IsCurrent = File.Exists(Path.Combine(path, ".current"))
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
|
||||
|
||||
// 确定要保留的版本
|
||||
var versionsToKeep = new HashSet<string>();
|
||||
|
||||
// 1. 总是保留当前版本
|
||||
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
|
||||
if (currentVersion != null)
|
||||
{
|
||||
versionsToKeep.Add(currentVersion.Path);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
|
||||
}
|
||||
|
||||
// 2. 保留最近的N个有效版本(不包括已标记destroy的)
|
||||
var activeVersions = validDeployments
|
||||
.Where(d => !d.IsDestroyed)
|
||||
.Take(minVersionsToKeep)
|
||||
.ToList();
|
||||
|
||||
foreach (var ver in activeVersions)
|
||||
{
|
||||
versionsToKeep.Add(ver.Path);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
|
||||
}
|
||||
|
||||
// 3. 保留有快照的版本(用于回滚)
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
|
||||
foreach (var snapshotFile in snapshotFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(snapshotFile);
|
||||
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
|
||||
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
|
||||
{
|
||||
if (Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
versionsToKeep.Add(snapshot.SourceDirectory);
|
||||
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略快照目录访问错误
|
||||
}
|
||||
}
|
||||
|
||||
// 清理不需要的版本
|
||||
foreach (var deployment in validDeployments)
|
||||
{
|
||||
if (versionsToKeep.Contains(deployment.Path))
|
||||
{
|
||||
// 保留此版本,如果之前标记了destroy则取消标记
|
||||
if (deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(Path.Combine(deployment.Path, ".destroy"));
|
||||
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略取消标记失败
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果还没标记destroy的,先标记
|
||||
if (!deployment.IsDestroyed)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
|
||||
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略标记失败
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试删除
|
||||
try
|
||||
{
|
||||
Directory.Delete(deployment.Path, recursive: true);
|
||||
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅清理已标记为.destroy的部署(兼容旧方法)
|
||||
/// </summary>
|
||||
[Obsolete("Use CleanupOldDeployments instead")]
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
CleanupOldDeployments(3);
|
||||
}
|
||||
|
||||
public static Version ParseVersionFromDirectory(string path)
|
||||
{
|
||||
var text = ParseVersionTextFromDirectory(path);
|
||||
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static string? ParseVersionTextFromDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-');
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return segments[1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从部署目录读取版本信息
|
||||
/// </summary>
|
||||
public AppVersionInfo GetVersionInfo()
|
||||
{
|
||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var versionFile = Path.Combine(deploymentDir, "version.json");
|
||||
if (File.Exists(versionFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFile);
|
||||
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
|
||||
if (info is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略读取失败,回退到默认值
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从目录名解析版本,使用默认开发代号
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = GetCurrentVersion(),
|
||||
Codename = "Administrate" // 默认开发代号
|
||||
};
|
||||
}
|
||||
}
|
||||
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
629
LanMountainDesktop.Launcher/Services/FlexibleHostLocator.cs
Normal file
@@ -0,0 +1,629 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 灵活的主程序定位器
|
||||
/// </summary>
|
||||
internal sealed class FlexibleHostLocator
|
||||
{
|
||||
private readonly HostDiscoveryOptions _options;
|
||||
private readonly string _appRoot;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
|
||||
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_options = options ?? new HostDiscoveryOptions();
|
||||
_deploymentLocator = new DeploymentLocator(appRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主程序可执行文件路径
|
||||
/// </summary>
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = GetExecutableName();
|
||||
var searchContext = new SearchContext
|
||||
{
|
||||
ExecutableName = executable,
|
||||
AppRoot = _appRoot,
|
||||
Options = _options
|
||||
};
|
||||
|
||||
// ========== 第一阶段:标准路径查找(快速路径)==========
|
||||
|
||||
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
|
||||
var envPath = GetPathFromEnvironment();
|
||||
if (!string.IsNullOrWhiteSpace(envPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(envPath, "environment variable");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 2. 使用 DeploymentLocator(ClassIsland 风格的简洁查询 - 优先)
|
||||
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
|
||||
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(deploymentDir))
|
||||
{
|
||||
var deploymentExePath = Path.Combine(deploymentDir, executable);
|
||||
if (File.Exists(deploymentExePath))
|
||||
{
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
|
||||
return deploymentExePath;
|
||||
}
|
||||
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
|
||||
}
|
||||
|
||||
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
|
||||
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
|
||||
var deploymentPath = SearchDeploymentDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(deploymentPath))
|
||||
{
|
||||
return deploymentPath;
|
||||
}
|
||||
|
||||
// 4. 检查 Launcher 同级目录(便携模式)
|
||||
var portablePath = SearchPortableLocation(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(portablePath))
|
||||
{
|
||||
return portablePath;
|
||||
}
|
||||
|
||||
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
|
||||
|
||||
// 5. 检查配置文件中的路径 - 用户自定义配置
|
||||
var configPath = GetPathFromConfigFile();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(configPath, "config file");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
|
||||
// 5. 搜索附近目录(向上、向下各一层)
|
||||
var nearbyPath = SearchNearbyDirectories(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(nearbyPath))
|
||||
{
|
||||
return nearbyPath;
|
||||
}
|
||||
|
||||
// 7. 开发模式:检查保存的自定义路径
|
||||
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedPath))
|
||||
{
|
||||
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
|
||||
if (validated != null) return validated;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 搜索标准开发路径
|
||||
var devPath = SearchDevelopmentPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(devPath))
|
||||
{
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// 9. 搜索额外的配置路径
|
||||
var additionalPath = SearchAdditionalPaths(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(additionalPath))
|
||||
{
|
||||
return additionalPath;
|
||||
}
|
||||
|
||||
// 10. 递归搜索(如果启用)
|
||||
if (_options.RecursiveSearch)
|
||||
{
|
||||
var recursivePath = SearchRecursively(searchContext);
|
||||
if (!string.IsNullOrWhiteSpace(recursivePath))
|
||||
{
|
||||
return recursivePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从环境变量获取路径
|
||||
/// </summary>
|
||||
private string? GetPathFromEnvironment()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从配置文件获取路径
|
||||
/// </summary>
|
||||
private string? GetPathFromConfigFile()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
|
||||
if (config?.HostPath != null && File.Exists(config.HostPath))
|
||||
{
|
||||
return config.HostPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略配置文件读取错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索部署目录
|
||||
/// </summary>
|
||||
private string? SearchDeploymentDirectories(SearchContext context)
|
||||
{
|
||||
if (!Directory.Exists(_appRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 查找 app-* 目录
|
||||
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
|
||||
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
|
||||
.ToList();
|
||||
|
||||
// 优先选择带 .current 标记的
|
||||
var currentMarked = appDirs
|
||||
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
|
||||
.Select(dir => Path.Combine(dir, context.ExecutableName))
|
||||
.FirstOrDefault(File.Exists);
|
||||
|
||||
if (currentMarked != null)
|
||||
{
|
||||
return currentMarked;
|
||||
}
|
||||
|
||||
// 选择版本号最高的
|
||||
var latest = appDirs
|
||||
.Select(dir => new
|
||||
{
|
||||
Dir = dir,
|
||||
Version = ParseVersionFromDirectoryName(dir)
|
||||
})
|
||||
.OrderByDescending(x => x.Version)
|
||||
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
|
||||
.FirstOrDefault(File.Exists);
|
||||
|
||||
return latest;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索便携模式位置(Launcher 同级目录)
|
||||
/// </summary>
|
||||
private string? SearchPortableLocation(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
|
||||
|
||||
if (File.Exists(portablePath))
|
||||
{
|
||||
return portablePath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索附近目录(灵活查找,适用于各种部署场景)
|
||||
/// </summary>
|
||||
private string? SearchNearbyDirectories(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchDirs = new List<string>();
|
||||
|
||||
// Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
searchDirs.Add(launcherDir);
|
||||
|
||||
// 上级目录
|
||||
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||
if (Directory.Exists(parentDir))
|
||||
{
|
||||
searchDirs.Add(parentDir);
|
||||
}
|
||||
|
||||
// 上上级目录
|
||||
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
|
||||
if (Directory.Exists(grandparentDir))
|
||||
{
|
||||
searchDirs.Add(grandparentDir);
|
||||
}
|
||||
|
||||
// AppRoot 及其上级
|
||||
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
|
||||
{
|
||||
searchDirs.Add(_appRoot);
|
||||
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
if (Directory.Exists(appParent))
|
||||
{
|
||||
searchDirs.Add(appParent);
|
||||
}
|
||||
}
|
||||
|
||||
// 去重后搜索
|
||||
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// 直接搜索
|
||||
var directPath = Path.Combine(dir, context.ExecutableName);
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// 搜索子目录(一层)
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(dir))
|
||||
{
|
||||
var subPath = Path.Combine(subDir, context.ExecutableName);
|
||||
if (File.Exists(subPath))
|
||||
{
|
||||
return subPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索开发路径
|
||||
/// </summary>
|
||||
private string? SearchDevelopmentPaths(SearchContext context)
|
||||
{
|
||||
// 获取 Launcher 所在目录
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 动态构建可能的开发路径(支持不同的项目结构)
|
||||
var possiblePaths = new List<string>();
|
||||
|
||||
// 从解决方案根目录搜索(支持不同的解决方案结构)
|
||||
var solutionRoot = FindSolutionRoot(launcherDir);
|
||||
if (!string.IsNullOrWhiteSpace(solutionRoot))
|
||||
{
|
||||
// 搜索所有可能的 bin 目录
|
||||
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
|
||||
}
|
||||
|
||||
// 添加硬编码的备用路径
|
||||
possiblePaths.AddRange(new[]
|
||||
{
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
|
||||
});
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索额外的配置路径
|
||||
/// </summary>
|
||||
private string? SearchAdditionalPaths(SearchContext context)
|
||||
{
|
||||
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pattern in _options.AdditionalSearchPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 替换变量
|
||||
var expandedPattern = ExpandVariables(pattern);
|
||||
|
||||
// 支持通配符
|
||||
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
|
||||
var filePattern = Path.GetFileName(expandedPattern);
|
||||
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
|
||||
var validMatch = matches.FirstOrDefault(File.Exists);
|
||||
if (validMatch != null)
|
||||
{
|
||||
return validMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (File.Exists(expandedPattern))
|
||||
{
|
||||
return expandedPattern;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归搜索
|
||||
/// </summary>
|
||||
private string? SearchRecursively(SearchContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
|
||||
|
||||
foreach (var searchDir in searchDirs.Where(Directory.Exists))
|
||||
{
|
||||
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略递归搜索错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归搜索目录
|
||||
/// </summary>
|
||||
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
|
||||
{
|
||||
if (depth > _options.MaxRecursionDepth)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 检查当前目录
|
||||
var directPath = Path.Combine(dir, executableName);
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// 检查子目录
|
||||
foreach (var subDir in Directory.GetDirectories(dir))
|
||||
{
|
||||
// 跳过某些目录
|
||||
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
|
||||
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略访问错误
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找解决方案根目录
|
||||
/// </summary>
|
||||
private string? FindSolutionRoot(string startDir)
|
||||
{
|
||||
var current = new DirectoryInfo(startDir);
|
||||
while (current != null)
|
||||
{
|
||||
// 查找 .sln 文件
|
||||
if (current.GetFiles("*.sln").Any())
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
// 查找 .git 目录作为备选
|
||||
if (current.GetDirectories(".git").Any())
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索 bin 目录
|
||||
/// </summary>
|
||||
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// 查找所有 bin 目录
|
||||
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var binDir in binDirs)
|
||||
{
|
||||
// 检查 Debug 和 Release 子目录
|
||||
var configDirs = new[] { "Debug", "Release" };
|
||||
foreach (var config in configDirs)
|
||||
{
|
||||
var configPath = Path.Combine(binDir, config);
|
||||
if (Directory.Exists(configPath))
|
||||
{
|
||||
// 检查所有 net* 子目录
|
||||
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
|
||||
foreach (var fwDir in frameworkDirs)
|
||||
{
|
||||
var exePath = Path.Combine(fwDir, executableName);
|
||||
if (File.Exists(exePath))
|
||||
{
|
||||
results.Add(exePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略搜索错误
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证路径并返回
|
||||
/// </summary>
|
||||
private string? ValidateAndReturn(string path, string source)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Debug.WriteLine($"Found host executable from {source}: {path}");
|
||||
return path;
|
||||
}
|
||||
|
||||
// 尝试添加 .exe(Windows)
|
||||
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var withExe = path + ".exe";
|
||||
if (File.Exists(withExe))
|
||||
{
|
||||
Debug.WriteLine($"Found host executable from {source}: {withExe}");
|
||||
return withExe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取可执行文件名
|
||||
/// </summary>
|
||||
private string GetExecutableName()
|
||||
{
|
||||
var name = _options.ExecutableName;
|
||||
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name += ".exe";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 展开路径变量
|
||||
/// </summary>
|
||||
private string ExpandVariables(string path)
|
||||
{
|
||||
return path
|
||||
.Replace("${AppRoot}", _appRoot)
|
||||
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
|
||||
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从目录名解析版本
|
||||
/// </summary>
|
||||
private static Version ParseVersionFromDirectoryName(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-');
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索上下文
|
||||
/// </summary>
|
||||
private class SearchContext
|
||||
{
|
||||
public required string ExecutableName { get; set; }
|
||||
public required string AppRoot { get; set; }
|
||||
public required HostDiscoveryOptions Options { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发现配置文件
|
||||
/// </summary>
|
||||
internal class HostDiscoveryConfig
|
||||
{
|
||||
public string? HostPath { get; set; }
|
||||
public List<string>? AdditionalPaths { get; set; }
|
||||
}
|
||||
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
47
LanMountainDesktop.Launcher/Services/HostDiscoveryOptions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 主程序发现选项
|
||||
/// </summary>
|
||||
public sealed class HostDiscoveryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 可执行文件名(Windows 下自动添加 .exe)
|
||||
/// </summary>
|
||||
public string ExecutableName { get; set; } = "LanMountainDesktop";
|
||||
|
||||
/// <summary>
|
||||
/// 额外的搜索路径(支持通配符)
|
||||
/// </summary>
|
||||
public List<string> AdditionalSearchPaths { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否递归搜索子目录
|
||||
/// </summary>
|
||||
public bool RecursiveSearch { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 递归搜索的最大深度
|
||||
/// </summary>
|
||||
public int MaxRecursionDepth { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 环境变量名称,用于指定自定义路径
|
||||
/// </summary>
|
||||
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
|
||||
|
||||
/// <summary>
|
||||
/// 配置文件路径(相对于 app root)
|
||||
/// </summary>
|
||||
public string? ConfigFileName { get; set; } = "host-discovery.json";
|
||||
|
||||
/// <summary>
|
||||
/// 是否优先使用开发模式配置
|
||||
/// </summary>
|
||||
public bool PreferDevModeConfig { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 搜索超时(毫秒)
|
||||
/// </summary>
|
||||
public int SearchTimeoutMs { get; set; } = 5000;
|
||||
}
|
||||
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
11
LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface ISplashStageReporter
|
||||
{
|
||||
void Report(string stage, string message);
|
||||
|
||||
/// <summary>
|
||||
/// 报告阶段和进度(0-100)
|
||||
/// </summary>
|
||||
void ReportStage(string stage, int progress);
|
||||
}
|
||||
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
192
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
|
||||
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// </summary>
|
||||
public class LauncherIpcServer : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Action<StartupProgressMessage> _onProgress;
|
||||
private Task? _listenTask;
|
||||
private NamedPipeServerStream? _currentPipe;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
|
||||
/// </summary>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||
{
|
||||
_onProgress = onProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 IPC 服务端监听
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
|
||||
}
|
||||
|
||||
private async Task ListenLoopAsync()
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
NamedPipeServerStream? pipe = null;
|
||||
try
|
||||
{
|
||||
pipe = new NamedPipeServerStream(
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Byte);
|
||||
|
||||
_currentPipe = pipe;
|
||||
await pipe.WaitForConnectionAsync(_cts.Token);
|
||||
|
||||
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
|
||||
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 客户端断开连接,继续等待新连接
|
||||
continue;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
|
||||
try
|
||||
{
|
||||
await Task.Delay(200, _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe?.Dispose();
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (ReferenceEquals(_currentPipe, pipe))
|
||||
{
|
||||
_currentPipe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从已连接的管道中持续读取消息,直到连接断开或取消
|
||||
/// </summary>
|
||||
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
|
||||
{
|
||||
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
|
||||
try
|
||||
{
|
||||
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// 1. 读取 4 字节长度前缀
|
||||
var totalRead = 0;
|
||||
while (totalRead < LengthPrefixSize)
|
||||
{
|
||||
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
// 连接已关闭
|
||||
return;
|
||||
}
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
|
||||
{
|
||||
// 无效长度,跳过此连接
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 读取消息正文
|
||||
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
|
||||
try
|
||||
{
|
||||
totalRead = 0;
|
||||
while (totalRead < payloadLength)
|
||||
{
|
||||
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
totalRead += read;
|
||||
}
|
||||
|
||||
// 3. 反序列化并回调
|
||||
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
|
||||
if (message is not null)
|
||||
{
|
||||
_onProgress(message);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略解析错误,继续读取下一条消息
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(payloadBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(lengthBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 IPC 服务端
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_currentPipe?.Dispose();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
786
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
786
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
@@ -0,0 +1,786 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
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;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly UpdateCheckService _updateCheckService;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
UpdateCheckService updateCheckService,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_updateCheckService = updateCheckService;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 清理旧版本,保留至少3个版本
|
||||
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
|
||||
// 检测老版本安装(首次运行时)
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
||||
if (legacyInfo != null)
|
||||
{
|
||||
var migrationResult = await ShowMigrationPromptAsync(legacyInfo);
|
||||
// 无论用户选择什么,都继续启动流程
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] Migration prompt result: {migrationResult}");
|
||||
}
|
||||
}
|
||||
|
||||
// 使用传入的 Splash 窗口或创建新的
|
||||
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
// 创建加载详情窗口(可选,用于显示详细加载状态)
|
||||
LoadingDetailsWindow? loadingDetailsWindow = null;
|
||||
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
loadingDetailsWindow = new LoadingDetailsWindow();
|
||||
loadingDetailsWindow.Show();
|
||||
});
|
||||
}
|
||||
|
||||
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
|
||||
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// 加载状态管理
|
||||
var loadingState = new LoadingStateMessage();
|
||||
|
||||
// 启动 IPC 服务端监听主程序进度
|
||||
using var ipcServer = new LauncherIpcServer(msg =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 更新加载状态
|
||||
loadingState = loadingState with
|
||||
{
|
||||
Stage = msg.Stage,
|
||||
OverallProgressPercent = msg.ProgressPercent,
|
||||
Message = msg.Message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 报告到 Splash 窗口
|
||||
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
|
||||
|
||||
// 更新加载详情窗口
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
|
||||
// 主程序报告就绪后,关闭 Splash 窗口和加载详情窗口
|
||||
if (msg.Stage == StartupStage.Ready)
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
loadingDetailsWindow?.Close();
|
||||
hostReadyTcs.TrySetResult();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error in IPC callback: {ex.Message}");
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcServer.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// 检查并安装待处理的更新(主程序下载的)
|
||||
reporter.Report("update", "检查更新...");
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
// 检查并安装待处理的插件更新
|
||||
reporter.Report("plugins", "检查插件更新...");
|
||||
var pluginsDir = _context.GetOption("plugins-dir")
|
||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return queueResult;
|
||||
}
|
||||
|
||||
// OOBE(首次运行引导)
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
|
||||
}
|
||||
|
||||
// 启动主程序
|
||||
reporter.Report("launch", "正在启动...");
|
||||
var (hostResult, hostProcess) = await LaunchHostWithIpcAsync(splashWindow);
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
// 等待主程序进程退出。Launcher 作为后台守护进程保持运行,
|
||||
// 维持 IPC 管道服务端供主程序报告启动进度。
|
||||
if (hostProcess is not null)
|
||||
{
|
||||
var processExitTask = hostProcess.WaitForExitAsync();
|
||||
|
||||
// 等待主程序就绪或进程退出(取先发生者)
|
||||
// 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
|
||||
var readyOrTimeoutOrExit = Task.WhenAny(
|
||||
hostReadyTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
var completedTask = await readyOrTimeoutOrExit;
|
||||
|
||||
// Host process exited before reporting Ready.
|
||||
if (completedTask == processExitTask)
|
||||
{
|
||||
var exitCode = hostProcess.ExitCode;
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||
|
||||
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
|
||||
exitCode,
|
||||
hostReadyTcs,
|
||||
splashWindow,
|
||||
loadingDetailsWindow).ConfigureAwait(false);
|
||||
if (recoveryResult is not null)
|
||||
{
|
||||
return recoveryResult;
|
||||
}
|
||||
|
||||
// Close Splash window for unrecoverable early exits.
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "host_crashed",
|
||||
Message = $"主程序异常退出,退出代码: {exitCode}"
|
||||
};
|
||||
}
|
||||
|
||||
// 如果 Splash 窗口仍然打开(超时情况),关闭它
|
||||
if (splashWindow.IsVisible)
|
||||
{
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Timeout waiting for Ready signal, closing splash window...");
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window on timeout: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 继续等待主程序进程退出(如果它还在运行)
|
||||
if (!hostProcess.HasExited)
|
||||
{
|
||||
await processExitTask;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果无法获取进程引用,退回到有限等待
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "exit",
|
||||
Code = "ok",
|
||||
Message = "Launcher completed successfully."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Splash 窗口可能已由 IPC Ready 回调关闭,这里做安全清理
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] Splash window closed in finally block");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window in finally: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// 优先使用自定义路径(调试模式选择的路径)
|
||||
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hostPath))
|
||||
{
|
||||
// 关闭 Splash 窗口
|
||||
// 显示错误窗口而不是直接退出
|
||||
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
|
||||
|
||||
if (errorResult == ErrorWindowResult.Retry)
|
||||
{
|
||||
// 用户选择重试,如果有选择路径则使用,否则重新尝试
|
||||
if (!string.IsNullOrWhiteSpace(selectedPath))
|
||||
{
|
||||
return await LaunchHostWithIpcAsync(splashWindow, selectedPath);
|
||||
}
|
||||
return await LaunchHostWithIpcAsync(splashWindow);
|
||||
}
|
||||
|
||||
// 用户选择退出
|
||||
return (new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launchHost",
|
||||
Code = "host_not_found",
|
||||
Message = "LanMountainDesktop host executable not found."
|
||||
}, null);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
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 = true,
|
||||
WorkingDirectory = hostWorkingDir,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
|
||||
// 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承)
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||
Environment.ProcessId.ToString();
|
||||
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||
_deploymentLocator.GetAppRoot();
|
||||
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,
|
||||
Stage = "launchHost",
|
||||
Code = "ok",
|
||||
Message = "Host launched."
|
||||
}, hostProcess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示找不到主程序的错误窗口
|
||||
/// </summary>
|
||||
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
// 在 UI 线程创建并显示错误窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
|
||||
errorWindow.Show();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow shown for host not found");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show ErrorWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
Console.Error.WriteLine("[LauncherFlowCoordinator] ErrorWindow is null, cannot wait for choice");
|
||||
return (ErrorWindowResult.Exit, null);
|
||||
}
|
||||
|
||||
// 等待用户选择
|
||||
ErrorWindowResult result;
|
||||
string? customPath;
|
||||
|
||||
try
|
||||
{
|
||||
result = await errorWindow.WaitForChoiceAsync();
|
||||
customPath = errorWindow.GetCustomHostPath();
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] ErrorWindow result: {result}, customPath: {customPath != null}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for choice: {ex.Message}");
|
||||
result = ErrorWindowResult.Exit;
|
||||
customPath = null;
|
||||
}
|
||||
|
||||
// 安全关闭错误窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
||||
{
|
||||
errorWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow closed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing ErrorWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return (result, customPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示迁移提示窗口
|
||||
/// </summary>
|
||||
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||
{
|
||||
MigrationPromptWindow? migrationWindow = null;
|
||||
|
||||
// 在 UI 线程创建并显示迁移提示窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
migrationWindow = new MigrationPromptWindow();
|
||||
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||
migrationWindow.Show();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow shown");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
if (migrationWindow is null)
|
||||
{
|
||||
Console.Error.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow is null, skipping migration prompt");
|
||||
return MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 等待用户选择
|
||||
MigrationResult result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await migrationWindow.WaitForChoiceAsync();
|
||||
Console.WriteLine($"[LauncherFlowCoordinator] MigrationPromptWindow result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for migration choice: {ex.Message}");
|
||||
result = MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
// 安全关闭窗口
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||
{
|
||||
migrationWindow.Close();
|
||||
Console.WriteLine("[LauncherFlowCoordinator] MigrationPromptWindow closed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing MigrationPromptWindow: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||
File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly OobeStateService _stateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService stateService)
|
||||
{
|
||||
_stateService = stateService;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
OobeWindow? window = null;
|
||||
|
||||
try
|
||||
{
|
||||
window = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE window shown");
|
||||
return oobeWindow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Failed to show OOBE window: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Console.Error.WriteLine("[WelcomeOobeStep] OOBE window is null, skipping OOBE");
|
||||
_stateService.MarkCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
using var _ = cancellationToken.Register(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (window.IsVisible && window.IsLoaded)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window on cancel: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE completed by user");
|
||||
_stateService.MarkCompleted();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (window is not null)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (window.IsVisible && window.IsLoaded)
|
||||
{
|
||||
window.Close();
|
||||
Console.WriteLine("[WelcomeOobeStep] OOBE window closed in finally");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window in finally: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
341
LanMountainDesktop.Launcher/Services/LegacyVersionDetector.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装
|
||||
/// </summary>
|
||||
internal sealed class LegacyVersionDetector
|
||||
{
|
||||
private const string LegacyAppName = "LanMountainDesktop";
|
||||
private const string LegacyExeName = "LanMountainDesktop.exe";
|
||||
|
||||
/// <summary>
|
||||
/// 检测是否存在老版本安装
|
||||
/// </summary>
|
||||
public static LegacyVersionInfo? DetectLegacyInstallation()
|
||||
{
|
||||
// 1. 检查注册表(安装版)
|
||||
var registryInfo = DetectFromRegistry();
|
||||
if (registryInfo != null)
|
||||
{
|
||||
return registryInfo;
|
||||
}
|
||||
|
||||
// 2. 检查常见安装目录
|
||||
var commonPaths = DetectFromCommonPaths();
|
||||
if (commonPaths != null)
|
||||
{
|
||||
return commonPaths;
|
||||
}
|
||||
|
||||
// 3. 检查便携版位置
|
||||
var portableInfo = DetectPortableInstallation();
|
||||
if (portableInfo != null)
|
||||
{
|
||||
return portableInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从注册表检测安装信息
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectFromRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall
|
||||
using var key = Registry.LocalMachine.OpenSubKey(
|
||||
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||
|
||||
if (key != null)
|
||||
{
|
||||
var installLocation = key.GetValue("InstallLocation") as string;
|
||||
var displayVersion = key.GetValue("DisplayVersion") as string;
|
||||
var uninstallString = key.GetValue("UninstallString") as string;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = displayVersion ?? "0.8.x",
|
||||
InstallPath = installLocation,
|
||||
UninstallCommand = uninstallString,
|
||||
InstallType = LegacyInstallType.Registry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 HKCU(用户级安装)
|
||||
using var userKey = Registry.CurrentUser.OpenSubKey(
|
||||
@$"Software\Microsoft\Windows\CurrentVersion\Uninstall\{LegacyAppName}");
|
||||
|
||||
if (userKey != null)
|
||||
{
|
||||
var installLocation = userKey.GetValue("InstallLocation") as string;
|
||||
var displayVersion = userKey.GetValue("DisplayVersion") as string;
|
||||
var uninstallString = userKey.GetValue("UninstallString") as string;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(installLocation) &&
|
||||
File.Exists(Path.Combine(installLocation, LegacyExeName)))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = displayVersion ?? "0.8.x",
|
||||
InstallPath = installLocation,
|
||||
UninstallCommand = uninstallString,
|
||||
InstallType = LegacyInstallType.Registry
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Registry detection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从常见安装路径检测
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectFromCommonPaths()
|
||||
{
|
||||
var commonPaths = new[]
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), LegacyAppName),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), LegacyAppName),
|
||||
};
|
||||
|
||||
foreach (var path in commonPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
// 检查是否存在老版本的特征文件(没有 app-* 目录)
|
||||
var exePath = Path.Combine(path, LegacyExeName);
|
||||
var hasAppDirs = Directory.GetDirectories(path, "app-*").Length > 0;
|
||||
|
||||
if (File.Exists(exePath) && !hasAppDirs)
|
||||
{
|
||||
// 尝试读取版本信息
|
||||
var version = TryGetFileVersion(exePath);
|
||||
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = version ?? "0.8.x",
|
||||
InstallPath = path,
|
||||
UninstallCommand = FindUninstaller(path),
|
||||
InstallType = LegacyInstallType.CommonPath
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Path detection failed for {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测便携版安装
|
||||
/// </summary>
|
||||
private static LegacyVersionInfo? DetectPortableInstallation()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查启动器所在目录的父目录(便携版常见布局)
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
|
||||
|
||||
if (Directory.Exists(parentDir))
|
||||
{
|
||||
var exePath = Path.Combine(parentDir, LegacyExeName);
|
||||
var hasAppDirs = Directory.GetDirectories(parentDir, "app-*").Length > 0;
|
||||
|
||||
// 如果存在 exe 且没有 app-* 目录,可能是老版本
|
||||
if (File.Exists(exePath) && !hasAppDirs)
|
||||
{
|
||||
var version = TryGetFileVersion(exePath);
|
||||
|
||||
// 检查是否真的是老版本(通过文件版本或特定标记)
|
||||
if (IsLegacyVersion(version))
|
||||
{
|
||||
return new LegacyVersionInfo
|
||||
{
|
||||
Version = version ?? "0.8.x",
|
||||
InstallPath = parentDir,
|
||||
UninstallCommand = null, // 便携版没有卸载程序
|
||||
InstallType = LegacyInstallType.Portable
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LegacyVersionDetector] Portable detection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找卸载程序
|
||||
/// </summary>
|
||||
private static string? FindUninstaller(string installPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 常见的卸载程序命名
|
||||
var uninstallerNames = new[] { "unins000.exe", "uninstall.exe", "Uninstall.exe" };
|
||||
|
||||
foreach (var name in uninstallerNames)
|
||||
{
|
||||
var path = Path.Combine(installPath, name);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件版本
|
||||
/// </summary>
|
||||
private static string? TryGetFileVersion(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionInfo = FileVersionInfo.GetVersionInfo(filePath);
|
||||
return versionInfo.FileVersion;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否为老版本(版本号 < 1.0.0)
|
||||
/// </summary>
|
||||
private static bool IsLegacyVersion(string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return true; // 无法确定版本时,保守认为是老版本
|
||||
}
|
||||
|
||||
if (Version.TryParse(version.Split(' ')[0], out var v))
|
||||
{
|
||||
return v.Major < 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开卸载界面
|
||||
/// </summary>
|
||||
public static void OpenUninstallInterface(LegacyVersionInfo info)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.UninstallCommand))
|
||||
{
|
||||
// 有卸载命令,直接执行
|
||||
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
|
||||
var fileName = parts[0].Trim('"');
|
||||
var arguments = parts.Length > 1 ? parts[1] : "";
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas" // 请求管理员权限
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有卸载命令,打开系统卸载面板
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "appwiz.cpl",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to open uninstall: {ex.Message}");
|
||||
|
||||
// 兜底:打开系统卸载面板
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "appwiz.cpl",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在资源管理器中显示老版本位置
|
||||
/// </summary>
|
||||
public static void ShowInExplorer(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LegacyVersionDetector] Failed to show in explorer: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 老版本信息
|
||||
/// </summary>
|
||||
public class LegacyVersionInfo
|
||||
{
|
||||
public string Version { get; set; } = "0.8.x";
|
||||
public string InstallPath { get; set; } = "";
|
||||
public string? UninstallCommand { get; set; }
|
||||
public LegacyInstallType InstallType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 老版本安装类型
|
||||
/// </summary>
|
||||
public enum LegacyInstallType
|
||||
{
|
||||
Registry, // 注册表安装版
|
||||
CommonPath, // 常见路径安装
|
||||
Portable // 便携版
|
||||
}
|
||||
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
138
LanMountainDesktop.Launcher/Services/Logger.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 简单的日志记录器 - 同时输出到控制台和文件
|
||||
/// </summary>
|
||||
internal static class Logger
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static string? _logFilePath;
|
||||
private static bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化日志记录器
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var logDir = GetLogDirectory();
|
||||
if (!string.IsNullOrEmpty(logDir))
|
||||
{
|
||||
Directory.CreateDirectory(logDir);
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
|
||||
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取日志文件路径
|
||||
/// </summary>
|
||||
public static string? GetLogFilePath()
|
||||
{
|
||||
return _logFilePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取日志目录
|
||||
/// </summary>
|
||||
private static string? GetLogDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(launcherDir, ".launcher", "logs");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录信息日志
|
||||
/// </summary>
|
||||
public static void Info(string message)
|
||||
{
|
||||
WriteLog("INFO", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录警告日志
|
||||
/// </summary>
|
||||
public static void Warn(string message)
|
||||
{
|
||||
WriteLog("WARN", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录错误日志
|
||||
/// </summary>
|
||||
public static void Error(string message)
|
||||
{
|
||||
WriteLog("ERROR", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录错误日志(带异常)
|
||||
/// </summary>
|
||||
public static void Error(string message, Exception exception)
|
||||
{
|
||||
WriteLog("ERROR", $"{message}\n{exception}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入日志
|
||||
/// </summary>
|
||||
private static void WriteLog(string level, string message)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var logLine = $"[{timestamp}] [{level}] {message}";
|
||||
|
||||
Console.WriteLine(logLine);
|
||||
|
||||
if (string.IsNullOrEmpty(_logFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
104
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
104
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
||||
string? stateDir = null;
|
||||
Exception? lastException = null;
|
||||
|
||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
||||
try
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
|
||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
stateDir = tempDir;
|
||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
||||
stateDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
||||
if (stateDir == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
||||
// 如果所有策略都失败,抛出异常让上层处理
|
||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
||||
}
|
||||
}
|
||||
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
try
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
||||
}
|
||||
}
|
||||
}
|
||||
226
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
226
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
||||
/// </summary>
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".lmdp";
|
||||
private const string RuntimeDirectoryName = "runtime";
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "plugin.install",
|
||||
Code = "ok",
|
||||
Message = "Plugin installed.",
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
CleanupPendingDeletions(pendingDeletionDir);
|
||||
}
|
||||
|
||||
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
var fileName = Path.GetFileName(existingPackagePath);
|
||||
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupPendingDeletions(string pendingDeletionDir)
|
||||
{
|
||||
if (!Directory.Exists(pendingDeletionDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(pendingFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 简化的插件清单模型
|
||||
/// </summary>
|
||||
public class PluginManifest
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public string? Author { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class PluginUpgradeQueueService
|
||||
{
|
||||
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
|
||||
|
||||
private readonly PluginInstallerService _installerService;
|
||||
|
||||
public PluginUpgradeQueueService(PluginInstallerService installerService)
|
||||
{
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "plugin.update",
|
||||
Code = "noop",
|
||||
Message = "No pending plugin upgrades."
|
||||
};
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(pendingPath);
|
||||
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
|
||||
var failures = new List<string>();
|
||||
var succeeded = new List<PendingUpgrade>();
|
||||
|
||||
foreach (var item in pending)
|
||||
{
|
||||
if (!item.IsValid())
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
}
|
||||
}
|
||||
|
||||
var remaining = pending
|
||||
.Except(succeeded)
|
||||
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
File.Delete(pendingPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = failures.Count == 0,
|
||||
Stage = "plugin.update",
|
||||
Code = failures.Count == 0 ? "ok" : "partial_failed",
|
||||
Message = failures.Count == 0
|
||||
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
|
||||
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
}
|
||||
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
161
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查服务 - 基于 GitHub Release API
|
||||
/// </summary>
|
||||
internal sealed class UpdateCheckService
|
||||
{
|
||||
private const string GitHubApiBase = "https://api.github.com";
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
_repoOwner = repoOwner;
|
||||
_repoName = repoName;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查更新
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await FetchReleasesAsync(cancellationToken);
|
||||
|
||||
// 根据频道过滤版本
|
||||
var filteredReleases = channel == UpdateChannel.Stable
|
||||
? releases.Where(r => !r.Prerelease).ToList()
|
||||
: releases;
|
||||
|
||||
// 找到最新版本
|
||||
var latestRelease = filteredReleases
|
||||
.OrderByDescending(r => ParseVersion(r.TagName))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latestRelease == null)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = "No releases found"
|
||||
};
|
||||
}
|
||||
|
||||
var latestVersion = ParseVersionString(latestRelease.TagName);
|
||||
var current = ParseVersion(currentVersion);
|
||||
var latest = ParseVersion(latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = latest > current,
|
||||
LatestVersion = latestVersion,
|
||||
CurrentVersion = currentVersion,
|
||||
Release = latestRelease
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Release
|
||||
/// </summary>
|
||||
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
TagName = r.TagName ?? "",
|
||||
Name = r.Name ?? "",
|
||||
Prerelease = r.Prerelease,
|
||||
PublishedAt = r.PublishedAt,
|
||||
Body = r.Body,
|
||||
Assets = r.Assets?.Select(a => new ReleaseAsset
|
||||
{
|
||||
Name = a.Name ?? "",
|
||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||
Size = a.Size
|
||||
}).ToList() ?? []
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
||||
/// </summary>
|
||||
private static string ParseVersionString(string tag)
|
||||
{
|
||||
return tag.TrimStart('v', 'V');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析版本号
|
||||
/// </summary>
|
||||
private static Version ParseVersion(string versionString)
|
||||
{
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
673
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
673
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
@@ -0,0 +1,673 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class UpdateEngineService
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignatureFileName = "files.json.sig";
|
||||
private const string ArchiveFileName = "update.zip";
|
||||
private const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly string _appRoot;
|
||||
private readonly string _launcherRoot;
|
||||
private readonly string _incomingRoot;
|
||||
private readonly string _snapshotsRoot;
|
||||
|
||||
public UpdateEngineService(DeploymentLocator deploymentLocator)
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
|
||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||
}
|
||||
|
||||
public LauncherResult CheckPendingUpdate()
|
||||
{
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "noop",
|
||||
Message = "No pending update."
|
||||
};
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
||||
if (fileMap is null)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
|
||||
}
|
||||
|
||||
var verified = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verified.Success)
|
||||
{
|
||||
return Failed("update.check", "signature_failed", verified.Message);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "available",
|
||||
Message = "Pending update is available.",
|
||||
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
|
||||
TargetVersion = fileMap.ToVersion
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(manifestPath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(signaturePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(archivePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.download",
|
||||
Code = "ok",
|
||||
Message = "Update downloaded."
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> ApplyPendingUpdateAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
Directory.CreateDirectory(_snapshotsRoot);
|
||||
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "noop",
|
||||
Message = "No update payload found."
|
||||
};
|
||||
}
|
||||
|
||||
var verifyResult = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verifyResult.Success)
|
||||
{
|
||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||
}
|
||||
|
||||
var fileMapText = await File.ReadAllTextAsync(fileMapPath);
|
||||
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.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
// 全新安装场景:没有当前部署目录,但有更新包
|
||||
// 这种情况下应该直接应用更新作为首次安装
|
||||
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
|
||||
}
|
||||
|
||||
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
||||
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
|
||||
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Failed(
|
||||
"update.apply",
|
||||
"version_mismatch",
|
||||
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
|
||||
}
|
||||
|
||||
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = currentVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = currentDeployment,
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
|
||||
|
||||
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||
try
|
||||
{
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
// 清理旧版本,但保留最近3个版本以支持回滚
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Updated to {targetVersion}.",
|
||||
CurrentVersion = currentVersion,
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "apply_failed",
|
||||
Message = "Failed to apply update. Rolled back to previous version.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = currentVersion,
|
||||
RolledBackTo = currentVersion
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全新安装场景:直接应用更新包作为首次部署
|
||||
/// </summary>
|
||||
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
|
||||
SignedFileMap fileMap,
|
||||
string archivePath,
|
||||
string fileMapPath,
|
||||
string signaturePath)
|
||||
{
|
||||
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!;
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json");
|
||||
|
||||
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||
try
|
||||
{
|
||||
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = "0.0.0",
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = "",
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
// 清理并解压更新包
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
// 创建目标部署目录
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
// 应用所有文件(全新安装时,所有文件都是新增或替换)
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
// 验证文件哈希
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// 激活部署(创建 .current 标记,删除 .partial 标记)
|
||||
var currentMarker = Path.Combine(targetDeployment, ".current");
|
||||
File.WriteAllText(currentMarker, string.Empty);
|
||||
if (File.Exists(partialMarker))
|
||||
{
|
||||
File.Delete(partialMarker);
|
||||
}
|
||||
|
||||
// 清理更新包
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Initial deployment to {targetVersion}.",
|
||||
CurrentVersion = "0.0.0",
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理失败的目标目录
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(targetDeployment))
|
||||
{
|
||||
Directory.Delete(targetDeployment, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "initial_deploy_failed",
|
||||
Message = "Failed to apply initial deployment.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = "0.0.0",
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用初始部署文件(全新安装场景,不需要源目录)
|
||||
/// </summary>
|
||||
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||
|
||||
// 删除操作在全新安装时忽略
|
||||
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||
var targetDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
// 无论是 add 还是 replace,都从压缩包复制
|
||||
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||
|
||||
if (!File.Exists(extractedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||
}
|
||||
|
||||
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
if (!Directory.Exists(_snapshotsRoot))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshotPath = Directory
|
||||
.EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(snapshotPath))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
|
||||
snapshot.Status = "manual_rollback";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.rollback",
|
||||
Code = "ok",
|
||||
Message = $"Rolled back to {snapshot.SourceVersion}.",
|
||||
RolledBackTo = snapshot.SourceVersion
|
||||
};
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (!File.Exists(Path.Combine(dir, ".destroy")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||
var targetDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(sourcePath, currentDeployment);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||
if (!File.Exists(extractedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||
}
|
||||
|
||||
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
private void ActivateDeployment(string fromDeployment, string toDeployment)
|
||||
{
|
||||
var toCurrent = Path.Combine(toDeployment, ".current");
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
|
||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||
|
||||
File.WriteAllText(toCurrent, string.Empty);
|
||||
if (File.Exists(fromCurrent))
|
||||
{
|
||||
File.Delete(fromCurrent);
|
||||
}
|
||||
|
||||
File.WriteAllText(fromDestroy, string.Empty);
|
||||
if (File.Exists(toPartial))
|
||||
{
|
||||
File.Delete(toPartial);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory))
|
||||
{
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
|
||||
{
|
||||
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
|
||||
}
|
||||
|
||||
if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current")))
|
||||
{
|
||||
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
Path.Combine(_incomingRoot, SignedFileMapName),
|
||||
Path.Combine(_incomingRoot, SignatureFileName),
|
||||
Path.Combine(_incomingRoot, ArchiveFileName)
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
|
||||
{
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
return (false, "Missing files.json.sig.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
return (false, $"Missing public key: {publicKeyPath}");
|
||||
}
|
||||
|
||||
var jsonBytes = File.ReadAllBytes(fileMapPath);
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
if (string.IsNullOrWhiteSpace(signatureBase64))
|
||||
{
|
||||
return (false, "Signature is empty.");
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (false, "Signature is not valid base64.");
|
||||
}
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
|
||||
var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return isValid ? (true, "ok") : (false, "Signature verification failed.");
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||||
return normalized.TrimStart(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool NeedsVerification(UpdateFileEntry file)
|
||||
{
|
||||
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(file.Sha256);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
private static LauncherResult Failed(string stage, string code, string message)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = stage,
|
||||
Code = code,
|
||||
Message = message,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 开发调试窗口 ViewModel
|
||||
/// </summary>
|
||||
public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isSplashEnabled = true;
|
||||
private bool _isErrorEnabled = true;
|
||||
private bool _isUpdateEnabled = true;
|
||||
private bool _isOobeEnabled = true;
|
||||
private string _statusMessage = "就绪";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
#region 页面开关
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsSplashEnabled
|
||||
{
|
||||
get => _isSplashEnabled;
|
||||
set
|
||||
{
|
||||
if (_isSplashEnabled != value)
|
||||
{
|
||||
_isSplashEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"启动画面: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误页面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsErrorEnabled
|
||||
{
|
||||
get => _isErrorEnabled;
|
||||
set
|
||||
{
|
||||
if (_isErrorEnabled != value)
|
||||
{
|
||||
_isErrorEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"错误页面: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新页面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsUpdateEnabled
|
||||
{
|
||||
get => _isUpdateEnabled;
|
||||
set
|
||||
{
|
||||
if (_isUpdateEnabled != value)
|
||||
{
|
||||
_isUpdateEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"更新页面: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OOBE页面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsOobeEnabled
|
||||
{
|
||||
get => _isOobeEnabled;
|
||||
set
|
||||
{
|
||||
if (_isOobeEnabled != value)
|
||||
{
|
||||
_isOobeEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"OOBE页面: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 状态信息
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (_statusMessage != value)
|
||||
{
|
||||
_statusMessage = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 命令
|
||||
|
||||
/// <summary>
|
||||
/// 打开启动画面命令
|
||||
/// </summary>
|
||||
public ICommand OpenSplashCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开错误页面命令
|
||||
/// </summary>
|
||||
public ICommand OpenErrorCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开更新页面命令
|
||||
/// </summary>
|
||||
public ICommand OpenUpdateCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开OOBE页面命令
|
||||
/// </summary>
|
||||
public ICommand OpenOobeCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 全部切换到查看模式命令
|
||||
/// </summary>
|
||||
public ICommand SetAllViewOnlyCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 全部切换到功能模式命令
|
||||
/// </summary>
|
||||
public ICommand SetAllFunctionalCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 关闭窗口命令
|
||||
/// </summary>
|
||||
public ICommand CloseCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region 事件
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开启动画面
|
||||
/// </summary>
|
||||
public event EventHandler<SplashOpenEventArgs>? OpenSplashRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开错误页面
|
||||
/// </summary>
|
||||
public event EventHandler<ErrorOpenEventArgs>? OpenErrorRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开更新页面
|
||||
/// </summary>
|
||||
public event EventHandler<UpdateOpenEventArgs>? OpenUpdateRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开OOBE页面
|
||||
/// </summary>
|
||||
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求关闭窗口
|
||||
/// </summary>
|
||||
public event EventHandler? CloseRequested;
|
||||
|
||||
#endregion
|
||||
|
||||
public DevDebugWindowViewModel()
|
||||
{
|
||||
OpenSplashCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenSplashRequested?.Invoke(this, new SplashOpenEventArgs(IsSplashEnabled));
|
||||
});
|
||||
|
||||
OpenErrorCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenErrorRequested?.Invoke(this, new ErrorOpenEventArgs(IsErrorEnabled));
|
||||
});
|
||||
|
||||
OpenUpdateCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenUpdateRequested?.Invoke(this, new UpdateOpenEventArgs(IsUpdateEnabled));
|
||||
});
|
||||
|
||||
OpenOobeCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
|
||||
});
|
||||
|
||||
SetAllViewOnlyCommand = new RelayCommand(() =>
|
||||
{
|
||||
IsSplashEnabled = false;
|
||||
IsErrorEnabled = false;
|
||||
IsUpdateEnabled = false;
|
||||
IsOobeEnabled = false;
|
||||
UpdateStatus("全部页面已切换到查看模式");
|
||||
});
|
||||
|
||||
SetAllFunctionalCommand = new RelayCommand(() =>
|
||||
{
|
||||
IsSplashEnabled = true;
|
||||
IsErrorEnabled = true;
|
||||
IsUpdateEnabled = true;
|
||||
IsOobeEnabled = true;
|
||||
UpdateStatus("全部页面已切换到功能模式");
|
||||
});
|
||||
|
||||
CloseCommand = new RelayCommand(() =>
|
||||
{
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateStatus(string message)
|
||||
{
|
||||
StatusMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
|
||||
#region 事件参数
|
||||
|
||||
public class SplashOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public SplashOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
public class ErrorOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public ErrorOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
public class UpdateOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public UpdateOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
public class OobeOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
#endregion
|
||||
67
LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
Normal file
67
LanMountainDesktop.Launcher/ViewModels/RelayCommand.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 简单的命令实现
|
||||
/// </summary>
|
||||
public class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action _execute;
|
||||
private readonly Func<bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
return _canExecute?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
_execute();
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public void RaiseCanExecuteChanged()
|
||||
{
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带参数的 RelayCommand
|
||||
/// </summary>
|
||||
public class RelayCommand<T> : ICommand
|
||||
{
|
||||
private readonly Action<T> _execute;
|
||||
private readonly Predicate<T>? _canExecute;
|
||||
|
||||
public RelayCommand(Action<T> execute, Predicate<T>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
return _canExecute?.Invoke((T)parameter!) ?? true;
|
||||
}
|
||||
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
_execute((T)parameter!);
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public void RaiseCanExecuteChanged()
|
||||
{
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
182
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
Normal file
182
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml
Normal file
@@ -0,0 +1,182 @@
|
||||
<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:vm="clr-namespace:LanMountainDesktop.Launcher.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.DevDebugWindow"
|
||||
x:DataType="vm:DevDebugWindowViewModel"
|
||||
Title="开发调试窗口 - Launcher"
|
||||
Width="500"
|
||||
Height="600"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:DevDebugWindowViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Border Padding="20"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- 标题 -->
|
||||
<StackPanel Grid.Row="0" Margin="0,0,0,20">
|
||||
<TextBlock Text="🛠️ 开发调试窗口"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
|
||||
<TextBlock Text="用于开发和调试 Launcher 的各个页面"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
Margin="0,5,0,0"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 页面列表 -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="15">
|
||||
|
||||
<!-- 启动画面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="🚀 启动画面 (SplashWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="显示启动进度和状态"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsSplashEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenSplashCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 错误页面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="❌ 错误页面 (ErrorWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="显示错误信息和重试选项"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsErrorEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenErrorCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 更新页面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="⬆️ 更新页面 (UpdateWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="显示更新进度和状态"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsUpdateEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenUpdateCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- OOBE页面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="👋 OOBE页面 (OobeWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="首次运行引导页面"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsOobeEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenOobeCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="10"
|
||||
Margin="0,15">
|
||||
<Button Content="全部设为查看模式"
|
||||
Command="{Binding SetAllViewOnlyCommand}"
|
||||
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}" />
|
||||
<Button Content="全部设为功能模式"
|
||||
Command="{Binding SetAllFunctionalCommand}"
|
||||
Background="{DynamicResource SystemControlHighlightAccentBrush}"
|
||||
Foreground="White" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="4"
|
||||
Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding StatusMessage}"
|
||||
FontSize="11"
|
||||
Opacity="0.8"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1"
|
||||
Content="关闭"
|
||||
Command="{Binding CloseCommand}"
|
||||
Padding="15,5" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
196
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
Normal file
196
LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Launcher.ViewModels;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 开发调试窗口
|
||||
/// </summary>
|
||||
public partial class DevDebugWindow : Window
|
||||
{
|
||||
private readonly DevDebugWindowViewModel _viewModel;
|
||||
|
||||
public DevDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
_viewModel = new DevDebugWindowViewModel();
|
||||
DataContext = _viewModel;
|
||||
|
||||
// 订阅事件
|
||||
_viewModel.OpenSplashRequested += OnOpenSplashRequested;
|
||||
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
|
||||
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
|
||||
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
|
||||
_viewModel.CloseRequested += OnCloseRequested;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开启动画面
|
||||
/// </summary>
|
||||
private void OnOpenSplashRequested(object? sender, SplashOpenEventArgs e)
|
||||
{
|
||||
var splashWindow = new SplashWindow();
|
||||
|
||||
if (!e.IsFunctional)
|
||||
{
|
||||
// 查看模式:显示模拟内容
|
||||
splashWindow.SetDebugMode(true);
|
||||
}
|
||||
|
||||
splashWindow.Show();
|
||||
|
||||
if (e.IsFunctional)
|
||||
{
|
||||
// 功能模式:模拟正常启动流程
|
||||
_ = SimulateSplashProgress(splashWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开错误页面
|
||||
/// </summary>
|
||||
private void OnOpenErrorRequested(object? sender, ErrorOpenEventArgs e)
|
||||
{
|
||||
var errorWindow = new ErrorWindow();
|
||||
|
||||
if (!e.IsFunctional)
|
||||
{
|
||||
// 查看模式:显示模拟错误
|
||||
errorWindow.SetDebugMode(true);
|
||||
errorWindow.SetErrorMessage("[调试模式] 这是一个模拟的错误消息,用于查看错误页面的样式和布局。");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 功能模式:显示真实错误
|
||||
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。\n\n请检查应用安装是否完整。");
|
||||
}
|
||||
|
||||
errorWindow.Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开更新页面
|
||||
/// </summary>
|
||||
private void OnOpenUpdateRequested(object? sender, UpdateOpenEventArgs e)
|
||||
{
|
||||
var updateWindow = new UpdateWindow();
|
||||
|
||||
if (!e.IsFunctional)
|
||||
{
|
||||
// 查看模式:显示模拟更新
|
||||
updateWindow.SetDebugMode(true);
|
||||
}
|
||||
|
||||
updateWindow.Show();
|
||||
|
||||
if (e.IsFunctional)
|
||||
{
|
||||
// 功能模式:模拟更新进度
|
||||
_ = SimulateUpdateProgress(updateWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开OOBE页面
|
||||
/// </summary>
|
||||
private void OnOpenOobeRequested(object? sender, OobeOpenEventArgs e)
|
||||
{
|
||||
var oobeWindow = new OobeWindow();
|
||||
|
||||
if (!e.IsFunctional)
|
||||
{
|
||||
// 查看模式:显示调试标记(通过标题)
|
||||
oobeWindow.Title = "[调试模式] 欢迎使用阑山桌面";
|
||||
}
|
||||
|
||||
oobeWindow.Show();
|
||||
|
||||
if (e.IsFunctional)
|
||||
{
|
||||
// 功能模式:等待用户点击后自动关闭
|
||||
_ = SimulateOobeProgress(oobeWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟OOBE流程
|
||||
/// </summary>
|
||||
private async Task SimulateOobeProgress(OobeWindow oobeWindow)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 等待用户点击开始按钮
|
||||
await oobeWindow.WaitForEnterAsync();
|
||||
|
||||
// 用户点击后,窗口会自动关闭(通过OobeWindow内部的动画和关闭逻辑)
|
||||
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DevDebugWindow] Error during OOBE simulation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭窗口
|
||||
/// </summary>
|
||||
private void OnCloseRequested(object? sender, EventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟启动画面进度
|
||||
/// </summary>
|
||||
private async Task SimulateSplashProgress(SplashWindow splashWindow)
|
||||
{
|
||||
var stages = new[] { "初始化", "检查更新", "加载组件", "启动应用" };
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
reporter.ReportStage(stages[i], (i + 1) * 25);
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
// 3秒后关闭
|
||||
await Task.Delay(3000);
|
||||
splashWindow.Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟更新进度
|
||||
/// </summary>
|
||||
private async Task SimulateUpdateProgress(UpdateWindow updateWindow)
|
||||
{
|
||||
var stages = new[] { "下载", "验证", "安装", "清理" };
|
||||
|
||||
foreach (var stage in stages)
|
||||
{
|
||||
updateWindow.Report(stage, $"正在{stage}...", Array.IndexOf(stages, stage) * 25 + 10);
|
||||
await Task.Delay(800);
|
||||
}
|
||||
|
||||
updateWindow.ReportComplete(true, null);
|
||||
|
||||
// 2秒后关闭
|
||||
await Task.Delay(2000);
|
||||
updateWindow.Close();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
// 取消订阅事件
|
||||
_viewModel.OpenSplashRequested -= OnOpenSplashRequested;
|
||||
_viewModel.OpenErrorRequested -= OnOpenErrorRequested;
|
||||
_viewModel.OpenUpdateRequested -= OnOpenUpdateRequested;
|
||||
_viewModel.OpenOobeRequested -= OnOpenOobeRequested;
|
||||
_viewModel.CloseRequested -= OnCloseRequested;
|
||||
|
||||
base.OnClosed(e);
|
||||
}
|
||||
}
|
||||
107
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
107
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
@@ -0,0 +1,107 @@
|
||||
<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="420"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
|
||||
x:DataType="views:ErrorDebugWindow"
|
||||
Title="调试模式"
|
||||
Width="420"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:ErrorDebugWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
|
||||
<!-- 标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="调试设置"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Margin="0,0,0,16" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<StackPanel Grid.Row="1" Spacing="16">
|
||||
<!-- 开发模式开关 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="开发模式"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="启用后自动扫描开发目录"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="DevModeToggle"
|
||||
Grid.Column="1"
|
||||
OnContent="开"
|
||||
OffContent="关" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 应用路径选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="应用路径"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Text="未选择"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="0,4,12,0" />
|
||||
<Button x:Name="BrowseButton"
|
||||
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||
Content="浏览..."
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Padding="12,10"
|
||||
IsVisible="True">
|
||||
<TextBlock Text="此功能仅供开发人员使用"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,16,0,0">
|
||||
<Button x:Name="CancelButton"
|
||||
Content="取消"
|
||||
Width="80"
|
||||
Height="32" />
|
||||
<Button x:Name="OkButton"
|
||||
Content="确定"
|
||||
Width="80"
|
||||
Height="32"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
172
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
172
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误调试窗口 - 开发人员专用调试设置
|
||||
/// </summary>
|
||||
public partial class ErrorDebugWindow : Window
|
||||
{
|
||||
private string? _selectedHostPath;
|
||||
private bool _isInitialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选择的主程序路径
|
||||
/// </summary>
|
||||
public string? SelectedHostPath => _selectedHostPath;
|
||||
|
||||
public ErrorDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||
{
|
||||
IsDevModeEnabled = devModeEnabled;
|
||||
_selectedHostPath = initialPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
|
||||
InitializeComponents();
|
||||
|
||||
// 设置初始值(在视觉树准备好后)
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||
}
|
||||
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
// 开发模式开关
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
{
|
||||
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
|
||||
}
|
||||
|
||||
// 浏览按钮
|
||||
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||
if (browseButton is not null)
|
||||
{
|
||||
browseButton.Click += OnBrowseClick;
|
||||
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
|
||||
}
|
||||
|
||||
// 确定按钮
|
||||
var okButton = this.FindControl<Button>("OkButton");
|
||||
if (okButton is not null)
|
||||
{
|
||||
okButton.Click += (s, e) => Close();
|
||||
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
if (cancelButton is not null)
|
||||
{
|
||||
cancelButton.Click += (s, e) =>
|
||||
{
|
||||
// 取消时恢复原始状态
|
||||
IsDevModeEnabled = false;
|
||||
_selectedHostPath = null;
|
||||
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
||||
Close();
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浏览按钮点击
|
||||
/// </summary>
|
||||
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var storageProvider = StorageProvider;
|
||||
if (storageProvider is null) return;
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "选择阑山桌面主程序",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("可执行文件")
|
||||
{
|
||||
Patterns = OperatingSystem.IsWindows()
|
||||
? new[] { "*.exe" }
|
||||
: new[] { "*" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count > 0)
|
||||
{
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新路径显示
|
||||
/// </summary>
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||
if (pathTextBlock is not null)
|
||||
{
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
||||
}
|
||||
}
|
||||
}
|
||||
105
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
105
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
@@ -0,0 +1,105 @@
|
||||
<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"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||
x:DataType="views:ErrorWindow"
|
||||
Title="阑山桌面"
|
||||
Width="520"
|
||||
Height="280"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:ErrorWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<!-- Fluent Design 风格对话框布局 -->
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 主内容区域:左侧图标 + 右侧文字 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:错误图标(可点击进入调试模式) -->
|
||||
<Border x:Name="ErrorIconBorder"
|
||||
Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text=""
|
||||
FontSize="24"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 右侧:标题 + 内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<!-- 标题 -->
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="启动失败"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<TextBlock x:Name="ErrorMessageText"
|
||||
Text="找不到阑山桌面应用程序。"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 建议信息 -->
|
||||
<TextBlock x:Name="SuggestionText"
|
||||
Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="18"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button x:Name="OpenLogButton"
|
||||
Grid.Column="0"
|
||||
Content="打开日志"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Left"/>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
542
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
542
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||
/// </summary>
|
||||
public partial class ErrorWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||
private int _iconClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugMode = false;
|
||||
private string? _customHostPath;
|
||||
private bool _devModeEnabled;
|
||||
|
||||
public ErrorWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 先加载保存的状态
|
||||
_devModeEnabled = LoadDevModeStateInternal();
|
||||
_customHostPath = LoadCustomHostPathInternal();
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件 - 视觉树已准备好
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
|
||||
InitializeComponents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件
|
||||
/// </summary>
|
||||
private void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window opened and visible");
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Initializing components...");
|
||||
|
||||
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||
if (errorIconBorder is not null)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
|
||||
}
|
||||
|
||||
// 按钮事件
|
||||
var retryButton = this.FindControl<Button>("RetryButton");
|
||||
var exitButton = this.FindControl<Button>("ExitButton");
|
||||
var openLogButton = this.FindControl<Button>("OpenLogButton");
|
||||
|
||||
if (retryButton is not null)
|
||||
{
|
||||
retryButton.Click += OnRetryClick;
|
||||
Console.WriteLine("[ErrorWindow] RetryButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
|
||||
}
|
||||
|
||||
if (exitButton is not null)
|
||||
{
|
||||
exitButton.Click += OnExitClick;
|
||||
Console.WriteLine("[ErrorWindow] ExitButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
|
||||
}
|
||||
|
||||
if (openLogButton is not null)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置错误消息
|
||||
/// </summary>
|
||||
public void SetErrorMessage(string message)
|
||||
{
|
||||
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||
if (errorText is not null)
|
||||
{
|
||||
errorText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
_isDebugMode = isDebugMode;
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
if (titleText is not null && isDebugMode)
|
||||
{
|
||||
titleText.Text = "[调试模式] 错误页面";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户选择的主程序路径
|
||||
/// </summary>
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||
{
|
||||
_iconClickCount++;
|
||||
|
||||
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||
{
|
||||
EnterDebugMode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入调试模式 - 显示调试窗口
|
||||
/// </summary>
|
||||
private async void EnterDebugMode()
|
||||
{
|
||||
_isDebugMode = true;
|
||||
|
||||
// 创建并显示调试窗口
|
||||
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅调试窗口关闭事件
|
||||
debugWindow.Closed += (s, e) =>
|
||||
{
|
||||
// 更新状态
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
|
||||
// 保存开发模式状态和自定义路径
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
|
||||
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
// 扫描到路径后也保存
|
||||
if (!string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径
|
||||
/// </summary>
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_customHostPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置存储的基础目录
|
||||
/// </summary>
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户状态)
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// LocalApplicationData 不可用,回退到 Launcher 所在目录
|
||||
}
|
||||
|
||||
// 回退方案:使用 Launcher 所在目录
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var configDir = Path.Combine(launcherDir, ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 最后的兜底:使用当前目录
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保配置目录存在
|
||||
/// </summary>
|
||||
private static bool EnsureConfigDirectory(string dirPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dirPath))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath);
|
||||
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
var enabled = content == "1";
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveCustomHostPathInternal(string? path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
|
||||
if (File.Exists(hostPathFile))
|
||||
{
|
||||
var content = File.ReadAllText(hostPathFile).Trim();
|
||||
// 验证路径是否仍然有效
|
||||
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
|
||||
return content;
|
||||
}
|
||||
|
||||
// 路径已失效,清理配置文件
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
|
||||
try
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static bool CheckDevModeEnabled()
|
||||
{
|
||||
return LoadDevModeStateInternal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static string? GetSavedCustomHostPath()
|
||||
{
|
||||
return LoadCustomHostPathInternal();
|
||||
}
|
||||
|
||||
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||
}
|
||||
|
||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开日志文件
|
||||
/// </summary>
|
||||
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = Logger.GetLogFilePath();
|
||||
|
||||
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
|
||||
{
|
||||
// 如果没有日志文件,打开日志目录
|
||||
var logDir = Path.GetDirectoryName(logFilePath);
|
||||
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
|
||||
{
|
||||
OpenFolder(logDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试打开配置目录
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDir))
|
||||
{
|
||||
OpenFolder(configDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] No log file or directory available");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
|
||||
OpenFile(logFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件
|
||||
/// </summary>
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{filePath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件夹
|
||||
/// </summary>
|
||||
private static void OpenFolder(string folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{folderPath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", folderPath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口用户选择结果
|
||||
/// </summary>
|
||||
public enum ErrorWindowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重试
|
||||
/// </summary>
|
||||
Retry,
|
||||
|
||||
/// <summary>
|
||||
/// 退出
|
||||
/// </summary>
|
||||
Exit
|
||||
}
|
||||
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
234
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml
Normal file
@@ -0,0 +1,234 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
|
||||
Title="LanMountain Desktop - Loading Details"
|
||||
Width="600"
|
||||
Height="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
CanResize="True"
|
||||
MinWidth="500"
|
||||
MinHeight="400"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Starting LanMountain Desktop"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
<TextBlock x:Name="SubtitleText"
|
||||
Text="Initializing..."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1"
|
||||
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="12,6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PercentText"
|
||||
Text="0%"
|
||||
FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" Margin="16,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ProgressBar x:Name="OverallProgressBar"
|
||||
Grid.Row="0"
|
||||
Height="8"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
CornerRadius="4"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16,12"
|
||||
Margin="0,0,0,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
|
||||
Width="40"
|
||||
Height="40"
|
||||
CornerRadius="20"
|
||||
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="CurrentItemIcon"
|
||||
Text=""
|
||||
FontSize="20"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="CurrentItemName"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="Initializing..."
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
|
||||
<TextBlock x:Name="CurrentItemDescription"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="Preparing components"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
|
||||
<ProgressBar x:Name="CurrentItemProgress"
|
||||
Height="4"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
CornerRadius="2"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="Loading Items"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock x:Name="CompletedCountText"
|
||||
Grid.Column="1"
|
||||
Text="0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,0,4,0"/>
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="Done"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Margin="8,0,8,8">
|
||||
<ItemsControl x:Name="LoadingItemsList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="views:LoadingItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="4,3"
|
||||
Opacity="{Binding Opacity}">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding StatusIcon}"
|
||||
FontSize="14"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{Binding StatusColor}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Name}"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding ProgressText}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<Border Grid.Column="3"
|
||||
Background="{Binding TypeBackground}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding TypeLabel}"
|
||||
FontSize="11"
|
||||
Foreground="{Binding TypeForeground}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="ErrorPanel"
|
||||
Grid.Row="2"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="12,10"
|
||||
Margin="16,0,16,12"
|
||||
IsVisible="False">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text=""
|
||||
FontSize="16"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="ErrorText"
|
||||
Grid.Column="1"
|
||||
Text="An error occurred while loading."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="VersionText"
|
||||
Grid.Column="0"
|
||||
Text="v1.0.0"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="DetailsButton"
|
||||
Content="Details"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="CancelButton"
|
||||
Content="Cancel"
|
||||
Width="90"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
396
LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 加载详情窗口 - 显示详细的加载状态和进度
|
||||
/// </summary>
|
||||
public partial class LoadingDetailsWindow : Window
|
||||
{
|
||||
private readonly ObservableCollection<LoadingItemViewModel> _items = new();
|
||||
private readonly DispatcherTimer _updateTimer;
|
||||
private DateTimeOffset _startTime;
|
||||
|
||||
public LoadingDetailsWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 初始化列表
|
||||
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
|
||||
if (itemsList != null)
|
||||
{
|
||||
itemsList.ItemsSource = _items;
|
||||
}
|
||||
|
||||
// 创建更新定时器
|
||||
_updateTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
_updateTimer.Tick += OnUpdateTimerTick;
|
||||
|
||||
_startTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成
|
||||
/// </summary>
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
_updateTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口关闭
|
||||
/// </summary>
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新加载状态
|
||||
/// </summary>
|
||||
public void UpdateLoadingState(LoadingStateMessage state)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 更新标题和副标题
|
||||
UpdateHeader(state);
|
||||
|
||||
// 更新整体进度
|
||||
UpdateOverallProgress(state);
|
||||
|
||||
// 更新当前活动项
|
||||
UpdateCurrentItem(state);
|
||||
|
||||
// 更新列表
|
||||
UpdateItemsList(state);
|
||||
|
||||
// 更新错误信息
|
||||
UpdateErrorPanel(state);
|
||||
|
||||
// 更新完成计数
|
||||
UpdateCompletedCount(state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[LoadingDetailsWindow] Error updating state: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新标题
|
||||
/// </summary>
|
||||
private void UpdateHeader(LoadingStateMessage state)
|
||||
{
|
||||
var subtitleText = this.FindControl<TextBlock>("SubtitleText");
|
||||
if (subtitleText != null)
|
||||
{
|
||||
subtitleText.Text = GetStageDescription(state.Stage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新整体进度
|
||||
/// </summary>
|
||||
private void UpdateOverallProgress(LoadingStateMessage state)
|
||||
{
|
||||
var progressBar = this.FindControl<ProgressBar>("OverallProgressBar");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
|
||||
if (progressBar != null)
|
||||
{
|
||||
progressBar.Value = state.OverallProgressPercent;
|
||||
}
|
||||
|
||||
if (percentText != null)
|
||||
{
|
||||
percentText.Text = $"{state.OverallProgressPercent}%";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前活动项
|
||||
/// </summary>
|
||||
private void UpdateCurrentItem(LoadingStateMessage state)
|
||||
{
|
||||
var currentItem = state.ActiveItems.FirstOrDefault();
|
||||
if (currentItem == null) return;
|
||||
|
||||
var nameText = this.FindControl<TextBlock>("CurrentItemName");
|
||||
var descText = this.FindControl<TextBlock>("CurrentItemDescription");
|
||||
var progressBar = this.FindControl<ProgressBar>("CurrentItemProgress");
|
||||
var iconText = this.FindControl<TextBlock>("CurrentItemIcon");
|
||||
|
||||
if (nameText != null)
|
||||
{
|
||||
nameText.Text = currentItem.Name;
|
||||
}
|
||||
|
||||
if (descText != null)
|
||||
{
|
||||
descText.Text = currentItem.Message ?? GetItemDescription(currentItem);
|
||||
}
|
||||
|
||||
if (progressBar != null)
|
||||
{
|
||||
progressBar.Value = currentItem.ProgressPercent;
|
||||
}
|
||||
|
||||
if (iconText != null)
|
||||
{
|
||||
iconText.Text = GetItemIcon(currentItem.Type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新列表
|
||||
/// </summary>
|
||||
private void UpdateItemsList(LoadingStateMessage state)
|
||||
{
|
||||
// 同步列表项
|
||||
foreach (var item in state.ActiveItems)
|
||||
{
|
||||
var existing = _items.FirstOrDefault(i => i.Id == item.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.UpdateFrom(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
_items.Add(new LoadingItemViewModel(item));
|
||||
}
|
||||
}
|
||||
|
||||
// 移除已完成的项(保留最近完成的5个)
|
||||
var completedItems = _items.Where(i => i.State == LoadingState.Completed).ToList();
|
||||
if (completedItems.Count > 5)
|
||||
{
|
||||
var itemsToRemove = completedItems.OrderBy(i => i.CompletedTime).Take(completedItems.Count - 5);
|
||||
foreach (var item in itemsToRemove)
|
||||
{
|
||||
_items.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
|
||||
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
|
||||
_items.Clear();
|
||||
foreach (var item in sortedItems)
|
||||
{
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新错误面板
|
||||
/// </summary>
|
||||
private void UpdateErrorPanel(LoadingStateMessage state)
|
||||
{
|
||||
var errorPanel = this.FindControl<Border>("ErrorPanel");
|
||||
var errorText = this.FindControl<TextBlock>("ErrorText");
|
||||
|
||||
if (errorPanel != null)
|
||||
{
|
||||
errorPanel.IsVisible = state.HasErrors;
|
||||
}
|
||||
|
||||
if (errorText != null && state.ErrorMessages?.Any() == true)
|
||||
{
|
||||
errorText.Text = string.Join("\n", state.ErrorMessages.Take(3));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新完成计数
|
||||
/// </summary>
|
||||
private void UpdateCompletedCount(LoadingStateMessage state)
|
||||
{
|
||||
var countText = this.FindControl<TextBlock>("CompletedCountText");
|
||||
if (countText != null)
|
||||
{
|
||||
countText.Text = state.CompletedCount.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时更新
|
||||
/// </summary>
|
||||
private void OnUpdateTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
// 可以在这里添加时间显示等实时更新
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取阶段描述
|
||||
/// </summary>
|
||||
private static string GetStageDescription(StartupStage stage) => stage switch
|
||||
{
|
||||
StartupStage.Initializing => "正在初始化系统...",
|
||||
StartupStage.LoadingSettings => "正在加载设置...",
|
||||
StartupStage.LoadingPlugins => "正在加载插件...",
|
||||
StartupStage.InitializingUI => "正在初始化界面...",
|
||||
StartupStage.Ready => "加载完成",
|
||||
_ => "正在加载..."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取项描述
|
||||
/// </summary>
|
||||
private static string GetItemDescription(LoadingItem item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Description))
|
||||
return item.Description;
|
||||
|
||||
return item.Type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "正在加载插件...",
|
||||
LoadingItemType.Component => "正在加载组件...",
|
||||
LoadingItemType.Resource => "正在加载资源...",
|
||||
LoadingItemType.Data => "正在加载数据...",
|
||||
LoadingItemType.Network => "正在下载...",
|
||||
_ => "正在处理..."
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取项图标
|
||||
/// </summary>
|
||||
private static string GetItemIcon(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "\uE768",
|
||||
LoadingItemType.Component => "\uE7C4",
|
||||
LoadingItemType.Resource => "\uE7C5",
|
||||
LoadingItemType.Data => "\uE7C6",
|
||||
LoadingItemType.Network => "\uE774",
|
||||
LoadingItemType.Settings => "\uE713",
|
||||
LoadingItemType.System => "\uE7C7",
|
||||
_ => "\uE768"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取状态优先级
|
||||
/// </summary>
|
||||
private static int GetStatePriority(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.InProgress => 0,
|
||||
LoadingState.Pending => 1,
|
||||
LoadingState.Completed => 2,
|
||||
LoadingState.Failed => 3,
|
||||
LoadingState.Timeout => 4,
|
||||
LoadingState.Cancelled => 5,
|
||||
_ => 6
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项视图模型
|
||||
/// </summary>
|
||||
public class LoadingItemViewModel : INotifyPropertyChanged
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Name { get; private set; }
|
||||
public LoadingItemType Type { get; private set; }
|
||||
public LoadingState State { get; private set; }
|
||||
public int ProgressPercent { get; private set; }
|
||||
public DateTimeOffset? CompletedTime { get; private set; }
|
||||
|
||||
public string StatusIcon => GetStatusIcon(State);
|
||||
public IBrush StatusColor => GetStatusColor(State);
|
||||
public string ProgressText => State == LoadingState.Completed ? "完成" : $"{ProgressPercent}%";
|
||||
public string TypeLabel => GetTypeLabel(Type);
|
||||
public IBrush TypeBackground => GetTypeBackground(Type);
|
||||
public IBrush TypeForeground => GetTypeForeground(Type);
|
||||
public double Opacity => State == LoadingState.Completed ? 0.6 : 1.0;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public LoadingItemViewModel(LoadingItem item)
|
||||
{
|
||||
Id = item.Id;
|
||||
UpdateFrom(item);
|
||||
}
|
||||
|
||||
public void UpdateFrom(LoadingItem item)
|
||||
{
|
||||
Name = item.Name;
|
||||
Type = item.Type;
|
||||
State = item.State;
|
||||
ProgressPercent = item.ProgressPercent;
|
||||
|
||||
if (State == LoadingState.Completed && !CompletedTime.HasValue)
|
||||
{
|
||||
CompletedTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
|
||||
}
|
||||
|
||||
private static string GetStatusIcon(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.Pending => "\uE7C3",
|
||||
LoadingState.InProgress => "\uE768",
|
||||
LoadingState.Completed => "\uE73E",
|
||||
LoadingState.Failed => "\uE783",
|
||||
LoadingState.Timeout => "\uE71A",
|
||||
LoadingState.Cancelled => "\uE711",
|
||||
_ => "\uE7C3"
|
||||
};
|
||||
|
||||
private static IBrush GetStatusColor(LoadingState state) => state switch
|
||||
{
|
||||
LoadingState.Pending => new SolidColorBrush(Colors.Gray),
|
||||
LoadingState.InProgress => new SolidColorBrush(Colors.DodgerBlue),
|
||||
LoadingState.Completed => new SolidColorBrush(Colors.Green),
|
||||
LoadingState.Failed => new SolidColorBrush(Colors.Red),
|
||||
LoadingState.Timeout => new SolidColorBrush(Colors.Orange),
|
||||
LoadingState.Cancelled => new SolidColorBrush(Colors.Gray),
|
||||
_ => new SolidColorBrush(Colors.Gray)
|
||||
};
|
||||
|
||||
private static string GetTypeLabel(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => "插件",
|
||||
LoadingItemType.Component => "组件",
|
||||
LoadingItemType.Resource => "资源",
|
||||
LoadingItemType.Data => "数据",
|
||||
LoadingItemType.Network => "网络",
|
||||
LoadingItemType.Settings => "设置",
|
||||
LoadingItemType.System => "系统",
|
||||
_ => "其他"
|
||||
};
|
||||
|
||||
private static IBrush GetTypeBackground(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#E3F2FD")),
|
||||
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#F3E5F5")),
|
||||
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#E8F5E9")),
|
||||
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#FFF3E0")),
|
||||
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#E0F7FA")),
|
||||
_ => new SolidColorBrush(Color.Parse("#F5F5F5"))
|
||||
};
|
||||
|
||||
private static IBrush GetTypeForeground(LoadingItemType type) => type switch
|
||||
{
|
||||
LoadingItemType.Plugin => new SolidColorBrush(Color.Parse("#1976D2")),
|
||||
LoadingItemType.Component => new SolidColorBrush(Color.Parse("#7B1FA2")),
|
||||
LoadingItemType.Resource => new SolidColorBrush(Color.Parse("#388E3C")),
|
||||
LoadingItemType.Data => new SolidColorBrush(Color.Parse("#F57C00")),
|
||||
LoadingItemType.Network => new SolidColorBrush(Color.Parse("#0097A7")),
|
||||
_ => new SolidColorBrush(Color.Parse("#616161"))
|
||||
};
|
||||
}
|
||||
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
149
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml
Normal file
@@ -0,0 +1,149 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="360"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.MigrationPromptWindow"
|
||||
x:DataType="views:MigrationPromptWindow"
|
||||
Title="阑山桌面 - 版本迁移"
|
||||
Width="520"
|
||||
Height="360"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:MigrationPromptWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:信息图标 -->
|
||||
<Border Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text=""
|
||||
FontSize="24"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 右侧:内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="12">
|
||||
<!-- 标题 -->
|
||||
<TextBlock Text="检测到旧版本"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<TextBlock x:Name="DescriptionText"
|
||||
Text="检测到您的系统中安装了旧版本的阑山桌面(0.8.4)。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 老版本信息卡片 -->
|
||||
<Border Margin="0,8,0,0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16,12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
|
||||
<!-- 版本号 -->
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="版本:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||
<TextBlock x:Name="VersionText"
|
||||
Grid.Row="0" Grid.Column="1"
|
||||
Text="0.8.4"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,0,0,0"/>
|
||||
|
||||
<!-- 安装路径 -->
|
||||
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||
Text="位置:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
<TextBlock x:Name="PathText"
|
||||
Grid.Row="1" Grid.Column="1"
|
||||
Text="C:\Program Files\LanMountainDesktop"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="8,4,0,0"/>
|
||||
|
||||
<!-- 安装类型 -->
|
||||
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||
Text="类型:"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
<TextBlock x:Name="TypeText"
|
||||
Grid.Row="2" Grid.Column="1"
|
||||
Text="安装版"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Margin="8,4,0,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<TextBlock Text="卸载旧版本不会影响新版本的使用,您的个人数据将保留。"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!-- 左侧:查看位置按钮 -->
|
||||
<Button x:Name="ShowLocationButton"
|
||||
Grid.Column="0"
|
||||
Content="查看位置"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="SkipButton"
|
||||
Content="暂不处理"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="UninstallButton"
|
||||
Content="卸载旧版本"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
157
LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 迁移提示窗口 - 提示用户卸载旧版本
|
||||
/// </summary>
|
||||
public partial class MigrationPromptWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<MigrationResult> _completionSource = new();
|
||||
private LegacyVersionInfo? _legacyInfo;
|
||||
|
||||
public MigrationPromptWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeEventHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置老版本信息
|
||||
/// </summary>
|
||||
public void SetLegacyInfo(LegacyVersionInfo info)
|
||||
{
|
||||
_legacyInfo = info;
|
||||
|
||||
// 更新 UI
|
||||
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||
var pathText = this.FindControl<TextBlock>("PathText");
|
||||
var typeText = this.FindControl<TextBlock>("TypeText");
|
||||
var descriptionText = this.FindControl<TextBlock>("DescriptionText");
|
||||
|
||||
if (versionText != null)
|
||||
{
|
||||
versionText.Text = info.Version;
|
||||
}
|
||||
|
||||
if (pathText != null)
|
||||
{
|
||||
pathText.Text = info.InstallPath;
|
||||
}
|
||||
|
||||
if (typeText != null)
|
||||
{
|
||||
typeText.Text = info.InstallType switch
|
||||
{
|
||||
LegacyInstallType.Registry => "安装版",
|
||||
LegacyInstallType.Portable => "便携版",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
if (descriptionText != null)
|
||||
{
|
||||
descriptionText.Text = $"检测到您的系统中安装了旧版本的阑山桌面({info.Version})。新版本采用了全新的架构,建议卸载旧版本以获得更好的体验。";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化事件处理程序
|
||||
/// </summary>
|
||||
private void InitializeEventHandlers()
|
||||
{
|
||||
var showLocationButton = this.FindControl<Button>("ShowLocationButton");
|
||||
var skipButton = this.FindControl<Button>("SkipButton");
|
||||
var uninstallButton = this.FindControl<Button>("UninstallButton");
|
||||
|
||||
if (showLocationButton != null)
|
||||
{
|
||||
showLocationButton.Click += OnShowLocationClick;
|
||||
}
|
||||
|
||||
if (skipButton != null)
|
||||
{
|
||||
skipButton.Click += OnSkipClick;
|
||||
}
|
||||
|
||||
if (uninstallButton != null)
|
||||
{
|
||||
uninstallButton.Click += OnUninstallClick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查看位置按钮点击
|
||||
/// </summary>
|
||||
private void OnShowLocationClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_legacyInfo != null)
|
||||
{
|
||||
LegacyVersionDetector.ShowInExplorer(_legacyInfo.InstallPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跳过按钮点击
|
||||
/// </summary>
|
||||
private void OnSkipClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卸载按钮点击
|
||||
/// </summary>
|
||||
private void OnUninstallClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_legacyInfo != null)
|
||||
{
|
||||
LegacyVersionDetector.OpenUninstallInterface(_legacyInfo);
|
||||
}
|
||||
|
||||
_completionSource.TrySetResult(MigrationResult.UninstallOpened);
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<MigrationResult> WaitForChoiceAsync()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口关闭事件
|
||||
/// </summary>
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// 如果还没有完成,标记为跳过
|
||||
if (!_completionSource.Task.IsCompleted)
|
||||
{
|
||||
_completionSource.TrySetResult(MigrationResult.Skipped);
|
||||
}
|
||||
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 迁移结果
|
||||
/// </summary>
|
||||
public enum MigrationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户选择跳过
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// 已打开卸载界面
|
||||
/// </summary>
|
||||
UninstallOpened
|
||||
}
|
||||
76
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
76
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
@@ -0,0 +1,76 @@
|
||||
<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"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
x:DataType="views:OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="600"
|
||||
Height="500"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid">
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
<StackPanel Grid.Row="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="24">
|
||||
|
||||
<!-- 顶部:完成状态勾号图标 -->
|
||||
<Border Width="80"
|
||||
Height="80"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="40"
|
||||
HorizontalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Accept"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 中央:欢迎文字 -->
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="你的桌面,不止一面"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- 底部:圆形开始按钮 -->
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Center"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Margin="0,0,0,16"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
CornerRadius="28">
|
||||
<ui:SymbolIcon Symbol="Forward"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
197
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
197
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// OOBE(首次使用体验)窗口 - 欢迎页面
|
||||
/// </summary>
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private bool _isTransitioning = false;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件 - 播放入场动画
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放入场动画
|
||||
/// </summary>
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取内容元素
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接返回
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡入动画
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 创建向上滑动动画
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 应用动画
|
||||
await fadeInAnimation.RunAsync(contentGrid);
|
||||
await slideUpAnimation.RunAsync(contentGrid);
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户点击开始按钮
|
||||
/// </summary>
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
/// <summary>
|
||||
/// 进入按钮点击事件
|
||||
/// </summary>
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
// 播放退出动画
|
||||
await PlayExitAnimationAsync();
|
||||
|
||||
// 完成 OOBE
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放退出动画
|
||||
/// </summary>
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
// 如果没有命名网格,直接延迟后返回
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建淡出动画
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
Easing = new CubicEaseIn(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
Console.WriteLine("[OobeWindow] Exit animation completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
87
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
87
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
@@ -0,0 +1,87 @@
|
||||
<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"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
x:DataType="views:SplashWindow"
|
||||
Title="LanMountain Desktop"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid>
|
||||
<!-- 左上角:应用名称 -->
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="24,24,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
Text="1.0.0 (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<!-- 右下角:阶段文字 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Opacity="0.8"
|
||||
Text="Initializing..." />
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
250
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
250
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private int _versionTextClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugModeOpened = false;
|
||||
|
||||
public SplashWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再绑定事件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
|
||||
|
||||
// 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
|
||||
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
|
||||
if (versionTextBorder is not null)
|
||||
{
|
||||
versionTextBorder.PointerPressed += OnVersionTextClick;
|
||||
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isDebugModeOpened) return;
|
||||
|
||||
_versionTextClickCount++;
|
||||
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
|
||||
|
||||
if (_versionTextClickCount >= DebugModeClickThreshold)
|
||||
{
|
||||
OpenDebugWindow();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开开发者调试窗口
|
||||
/// </summary>
|
||||
private async void OpenDebugWindow()
|
||||
{
|
||||
_isDebugModeOpened = true;
|
||||
Console.WriteLine("[SplashWindow] Opening debug window...");
|
||||
|
||||
try
|
||||
{
|
||||
// 加载保存的状态
|
||||
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
|
||||
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
|
||||
|
||||
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
};
|
||||
|
||||
// 订阅窗口关闭事件以保存状态
|
||||
debugWindow.Closed += (s, e) =>
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Debug window closed");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度和状态
|
||||
/// </summary>
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态文本
|
||||
statusText.Text = message;
|
||||
|
||||
// 根据阶段更新进度
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度(0-100)
|
||||
/// </summary>
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
|
||||
return;
|
||||
}
|
||||
statusText.Text = message;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告阶段和进度(0-100)
|
||||
/// </summary>
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.Text = stage;
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置版本和开发代号
|
||||
/// </summary>
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||
if (versionText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
|
||||
return;
|
||||
}
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
|
||||
return;
|
||||
}
|
||||
if (isDebugMode)
|
||||
{
|
||||
statusText.Text = "[Debug Mode] Splash Preview";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据阶段名称解析进度值
|
||||
/// </summary>
|
||||
private static int ResolveProgress(string stage)
|
||||
{
|
||||
return stage.ToLowerInvariant() switch
|
||||
{
|
||||
"initializing" => 10,
|
||||
"update" => 30,
|
||||
"plugins" => 50,
|
||||
"launch" => 70,
|
||||
"ready" => 100,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
108
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
Normal file
108
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml
Normal file
@@ -0,0 +1,108 @@
|
||||
<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="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
|
||||
x:DataType="views:UpdateWindow"
|
||||
Title="阑山桌面 - 更新"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:UpdateWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid>
|
||||
<!-- 顶部:应用名称和最小化按钮 -->
|
||||
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="阑山桌面"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="Update"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 最小化按钮 -->
|
||||
<Button x:Name="MinimizeButton"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
<TextBlock Text=""
|
||||
FontSize="12"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 第一行:左下角状态,右下角百分比 -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左下角:状态文字 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="0"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="正在更新,请稍候..." />
|
||||
|
||||
<!-- 右下角:百分比 -->
|
||||
<TextBlock x:Name="PercentText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="0%" />
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="True"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
123
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
Normal file
123
LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
|
||||
/// </summary>
|
||||
public partial class UpdateWindow : Window
|
||||
{
|
||||
public UpdateWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeEventHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化事件处理程序
|
||||
/// </summary>
|
||||
private void InitializeEventHandlers()
|
||||
{
|
||||
var minimizeButton = this.FindControl<Button>("MinimizeButton");
|
||||
if (minimizeButton != null)
|
||||
{
|
||||
minimizeButton.Click += (s, e) =>
|
||||
{
|
||||
this.WindowState = WindowState.Minimized;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态和进度
|
||||
/// </summary>
|
||||
public void Report(string stage, string message, int progressPercent = -1)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || percentText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.Text = message;
|
||||
|
||||
if (progressPercent >= 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progressPercent;
|
||||
percentText.Text = $"{progressPercent}%";
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
percentText.Text = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示更新完成状态
|
||||
/// </summary>
|
||||
public void ReportComplete(bool success, string? errorMessage = null)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
var percentText = this.FindControl<TextBlock>("PercentText");
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
|
||||
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
|
||||
return;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = 100;
|
||||
percentText.Text = "100%";
|
||||
|
||||
if (success)
|
||||
{
|
||||
statusText.Text = "更新完成";
|
||||
}
|
||||
else
|
||||
{
|
||||
titleText.Text = "更新失败";
|
||||
statusText.Text = errorMessage ?? "更新过程中发生错误";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
|
||||
if (statusText is null || titleText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[UpdateWindow] Controls not found in SetDebugMode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDebugMode)
|
||||
{
|
||||
titleText.Text = "[调试模式] 更新页面";
|
||||
statusText.Text = "预览更新进度界面";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
25
LanMountainDesktop.Launcher/app.manifest
Normal file
25
LanMountainDesktop.Launcher/app.manifest
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
|
||||
<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>
|
||||
<!-- Windows 10/11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,290 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var result = new HelperResult();
|
||||
string? resultPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var parsedArgs = ParseArgs(args);
|
||||
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||
string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||
}
|
||||
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
resultPath = Path.GetFullPath(resultPath);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = true,
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(resultDirectory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
resultPath,
|
||||
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}),
|
||||
Encoding.UTF8);
|
||||
}
|
||||
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or malformed packages while replacing an install target.
|
||||
}
|
||||
}
|
||||
|
||||
CleanupPendingDeletions(pendingDeletionDir);
|
||||
}
|
||||
|
||||
private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
var fileName = Path.GetFileName(existingPackagePath);
|
||||
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
|
||||
try
|
||||
{
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
catch (IOException moveEx)
|
||||
{
|
||||
throw new IOException(
|
||||
$"Cannot delete or move existing plugin package '{existingPackagePath}'. " +
|
||||
$"The file may be in use by another process. Error: {moveEx.Message}", moveEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupPendingDeletions(string pendingDeletionDir)
|
||||
{
|
||||
if (!Directory.Exists(pendingDeletionDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(pendingFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures for pending deletions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private sealed class HelperResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 应用版本信息
|
||||
/// </summary>
|
||||
public record AppVersionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 版本号,如 "1.0.0"
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// 开发代号,如 "Administrate"
|
||||
/// </summary>
|
||||
public string Codename { get; init; } = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 完整版本字符串,如 "1.0.0 (Administrate)"
|
||||
/// </summary>
|
||||
public string FullVersionText => $"{Version} ({Codename})";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
89
LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
Normal file
89
LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 启动阶段枚举
|
||||
/// </summary>
|
||||
public enum StartupStage
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化中
|
||||
/// </summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>
|
||||
/// 加载设置中
|
||||
/// </summary>
|
||||
LoadingSettings,
|
||||
|
||||
/// <summary>
|
||||
/// 加载插件中
|
||||
/// </summary>
|
||||
LoadingPlugins,
|
||||
|
||||
/// <summary>
|
||||
/// 初始化界面中
|
||||
/// </summary>
|
||||
InitializingUI,
|
||||
|
||||
/// <summary>
|
||||
/// 就绪
|
||||
/// </summary>
|
||||
Ready
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动进度消息
|
||||
/// </summary>
|
||||
public record StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 常量
|
||||
/// </summary>
|
||||
public static class LauncherIpcConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// 命名管道名称
|
||||
/// </summary>
|
||||
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||
|
||||
/// <summary>
|
||||
/// Launcher 进程 ID 环境变量
|
||||
/// </summary>
|
||||
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||
|
||||
/// <summary>
|
||||
/// 包根目录环境变量
|
||||
/// </summary>
|
||||
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||
|
||||
/// <summary>
|
||||
/// 版本环境变量
|
||||
/// </summary>
|
||||
public const string VersionEnvVar = "LMD_VERSION";
|
||||
|
||||
/// <summary>
|
||||
/// 开发代号环境变量
|
||||
/// </summary>
|
||||
public const string CodenameEnvVar = "LMD_CODENAME";
|
||||
}
|
||||
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal file
231
LanMountainDesktop.Shared.Contracts/Launcher/LoadingState.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public enum LoadingItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统初始化
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// 设置加载
|
||||
/// </summary>
|
||||
Settings,
|
||||
|
||||
/// <summary>
|
||||
/// 插件
|
||||
/// </summary>
|
||||
Plugin,
|
||||
|
||||
/// <summary>
|
||||
/// 组件
|
||||
/// </summary>
|
||||
Component,
|
||||
|
||||
/// <summary>
|
||||
/// 资源
|
||||
/// </summary>
|
||||
Resource,
|
||||
|
||||
/// <summary>
|
||||
/// 数据
|
||||
/// </summary>
|
||||
Data,
|
||||
|
||||
/// <summary>
|
||||
/// 网络请求
|
||||
/// </summary>
|
||||
Network,
|
||||
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态
|
||||
/// </summary>
|
||||
public enum LoadingState
|
||||
{
|
||||
/// <summary>
|
||||
/// 等待中
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// 进行中
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// 超时
|
||||
/// </summary>
|
||||
Timeout
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项信息
|
||||
/// </summary>
|
||||
public record LoadingItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载项唯一标识
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public LoadingItemType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项名称
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项描述
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前状态
|
||||
/// </summary>
|
||||
public LoadingState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(当 State 为 Failed 时)
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预计剩余时间(秒)
|
||||
/// </summary>
|
||||
public int? EstimatedRemainingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem>? Children { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外数据
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态更新消息
|
||||
/// </summary>
|
||||
public record LoadingStateMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比 (0-100)
|
||||
/// </summary>
|
||||
public int OverallProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前活动的加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem> ActiveItems { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 已完成的加载项数量
|
||||
/// </summary>
|
||||
public int CompletedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总加载项数量
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
public bool HasErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误消息列表
|
||||
/// </summary>
|
||||
public List<string>? ErrorMessages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 详细的加载进度消息(用于实时更新)
|
||||
/// </summary>
|
||||
public record DetailedProgressMessage : StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前加载项
|
||||
/// </summary>
|
||||
public LoadingItem? CurrentItem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有加载项
|
||||
/// </summary>
|
||||
public List<LoadingItem>? AllItems { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为主要更新
|
||||
/// </summary>
|
||||
public bool IsMajorUpdate { get; init; }
|
||||
}
|
||||
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SingleInstanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
primary.StartActivationListener(() => activated.TrySetResult());
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
|
||||
Assert.True(acknowledged);
|
||||
Assert.Null(failureReason);
|
||||
|
||||
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(activated.Task, completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
|
||||
|
||||
Assert.False(acknowledged);
|
||||
Assert.False(string.IsNullOrWhiteSpace(failureReason));
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateService(string mutexName, string pipeName)
|
||||
{
|
||||
var ctor = typeof(SingleInstanceService).GetConstructor(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
[typeof(string), typeof(string)],
|
||||
modifiers: null);
|
||||
|
||||
Assert.NotNull(ctor);
|
||||
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
|
||||
{
|
||||
SingleInstanceService? created = null;
|
||||
Exception? creationError = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
created = CreateService(mutexName, pipeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
creationError = ex;
|
||||
}
|
||||
});
|
||||
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (creationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
|
||||
}
|
||||
|
||||
Assert.NotNull(created);
|
||||
return created!;
|
||||
}
|
||||
|
||||
private static void MarkAsSecondaryForTest(SingleInstanceService service)
|
||||
{
|
||||
var ownsMutexField = typeof(SingleInstanceService).GetField(
|
||||
"_ownsMutex",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(ownsMutexField);
|
||||
ownsMutexField!.SetValue(service, false);
|
||||
Assert.False(service.IsPrimaryInstance);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
|
||||
217
LanMountainDesktop/.github/workflows/windows-ci.yml
vendored
217
LanMountainDesktop/.github/workflows/windows-ci.yml
vendored
@@ -1,217 +0,0 @@
|
||||
name: Desktop CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Package version override (for example: 1.2.3)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: desktop-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
PROJECT_PATH: "LanMountainDesktop.csproj"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Build (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
**/*.csproj
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore .\${{ env.PROJECT_PATH }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build .\${{ env.PROJECT_PATH }} -c Release --no-restore
|
||||
|
||||
- name: Test (if test projects exist)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testProjects = @(Get-ChildItem -Path . -Recurse -Filter *.csproj | Where-Object {
|
||||
Select-String -Path $_.FullName -Pattern '<IsTestProject>\s*true\s*</IsTestProject>|Microsoft.NET.Test.Sdk' -Quiet
|
||||
})
|
||||
|
||||
if ($testProjects.Count -eq 0) {
|
||||
Write-Host "No test projects found. Skipping dotnet test."
|
||||
exit 0
|
||||
}
|
||||
|
||||
foreach ($project in $testProjects) {
|
||||
Write-Host "Running tests in $($project.FullName)"
|
||||
dotnet test $project.FullName -c Release --verbosity normal
|
||||
}
|
||||
|
||||
resolve_version:
|
||||
name: Resolve Package Version
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
outputs:
|
||||
value: ${{ steps.version.outputs.value }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manualVersion = '${{ github.event.inputs.version }}'
|
||||
if ($manualVersion) {
|
||||
$version = $manualVersion.Trim()
|
||||
} elseif ($env:GITHUB_REF -like "refs/tags/v*") {
|
||||
$version = $env:GITHUB_REF_NAME.Substring(1)
|
||||
} elseif ($env:GITHUB_REF -like "refs/tags/*") {
|
||||
$version = $env:GITHUB_REF_NAME
|
||||
} else {
|
||||
$version = "0.0.$env:GITHUB_RUN_NUMBER"
|
||||
}
|
||||
|
||||
if (-not $version) {
|
||||
throw "Failed to resolve package version."
|
||||
}
|
||||
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+([\-+][0-9A-Za-z\.-]+)?$') {
|
||||
throw "Invalid version format: $version"
|
||||
}
|
||||
|
||||
"value=$version" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "Using package version: $version"
|
||||
|
||||
package:
|
||||
name: Package (${{ matrix.name }})
|
||||
needs:
|
||||
- validate
|
||||
- resolve_version
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
runner: windows-latest
|
||||
rid: win-x64
|
||||
artifact_name: LanMountainDesktop-Setup
|
||||
artifact_path: artifacts/installer/*.exe
|
||||
- name: Linux
|
||||
runner: ubuntu-latest
|
||||
rid: linux-x64
|
||||
artifact_name: LanMountainDesktop-linux-x64
|
||||
artifact_path: artifacts/packages/*linux-x64*.zip
|
||||
- name: macOS
|
||||
runner: macos-latest
|
||||
rid: osx-x64
|
||||
artifact_name: LanMountainDesktop-osx-x64
|
||||
artifact_path: artifacts/packages/*osx-x64*.zip
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
**/*.csproj
|
||||
|
||||
- name: Install Inno Setup
|
||||
if: matrix.rid == 'win-x64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Get-Command iscc.exe -ErrorAction SilentlyContinue) {
|
||||
Write-Host "Inno Setup is already installed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Get-Command choco -ErrorAction SilentlyContinue) {
|
||||
choco install innosetup --yes --no-progress
|
||||
} elseif (Get-Command winget -ErrorAction SilentlyContinue) {
|
||||
winget install --id JRSoftware.InnoSetup -e --source winget --accept-package-agreements --accept-source-agreements
|
||||
} else {
|
||||
throw "Neither choco nor winget is available to install Inno Setup."
|
||||
}
|
||||
|
||||
- name: Build Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/package.ps1 `
|
||||
-Configuration Release `
|
||||
-RuntimeIdentifier ${{ matrix.rid }} `
|
||||
-Version "${{ needs.resolve_version.outputs.value }}"
|
||||
|
||||
- name: Upload Package Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}-${{ needs.resolve_version.outputs.value }}
|
||||
path: ${{ matrix.artifact_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows Publish Artifact
|
||||
if: matrix.rid == 'win-x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LanMountainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }}
|
||||
path: artifacts/publish/win-x64/**
|
||||
if-no-files-found: error
|
||||
|
||||
publish_release_assets:
|
||||
name: Attach Artifacts to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- package
|
||||
- resolve_version
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Windows Installer Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: LanMountainDesktop-Setup-${{ needs.resolve_version.outputs.value }}
|
||||
path: release-assets/windows
|
||||
|
||||
- name: Download Linux Package Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: LanMountainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }}
|
||||
path: release-assets/linux
|
||||
|
||||
- name: Download macOS Package Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: LanMountainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }}
|
||||
path: release-assets/macos
|
||||
|
||||
- name: Attach Artifacts
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release-assets/windows/*.exe
|
||||
release-assets/linux/*.zip
|
||||
release-assets/macos/*.zip
|
||||
@@ -19,7 +19,10 @@ using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Loading;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
@@ -71,6 +74,11 @@ public partial class App : Application
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
@@ -145,6 +153,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
@@ -155,6 +164,104 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
|
||||
// 使用 fire-and-forget 模式,不阻塞主流程
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
}
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = await _launcherIpcClient.ConnectAsync();
|
||||
|
||||
if (connected)
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||
|
||||
// 初始化加载状态管理器
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
// 注册系统初始化加载项
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
|
||||
_loadingStateManager.StartItem("system.init", "已连接启动器");
|
||||
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to initialize Launcher IPC: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向 Launcher 报告启动进度(fire-and-forget,不阻塞主流程)
|
||||
/// </summary>
|
||||
private void ReportStartupProgress(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
|
||||
/// 用于 Ready 等关键状态报告
|
||||
/// </summary>
|
||||
private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
|
||||
{
|
||||
if (_launcherIpcClient is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = percent,
|
||||
Message = message
|
||||
});
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDesignTimeTheme()
|
||||
@@ -182,18 +289,23 @@ public partial class App : Application
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
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(
|
||||
@@ -322,6 +434,7 @@ public partial class App : Application
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
ReportStartupProgress(StartupStage.LoadingPlugins, 30, "正在加载插件...");
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
@@ -552,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()
|
||||
{
|
||||
@@ -778,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)
|
||||
@@ -828,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();
|
||||
@@ -869,9 +1081,57 @@ public partial class App : Application
|
||||
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
|
||||
LogBrowserStartupDiagnostics();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
|
||||
|
||||
// 延迟报告 Ready 直到窗口实际打开并可见
|
||||
// 使用 Opened 事件确保所有资源已加载完毕
|
||||
mainWindow.Opened += OnMainWindowOpened;
|
||||
|
||||
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
|
||||
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Ready,
|
||||
ProgressPercent = 100,
|
||||
Message = "就绪"
|
||||
});
|
||||
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主窗口打开完成事件 - 此时所有组件、资源及功能模块均已完全加载
|
||||
/// </summary>
|
||||
private void OnMainWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is MainWindow mainWindow)
|
||||
{
|
||||
mainWindow.Opened -= OnMainWindowOpened;
|
||||
|
||||
AppLogger.Info("App", "Main window opened and ready. Reporting Ready to Launcher...");
|
||||
|
||||
// 完成系统初始化加载项
|
||||
_loadingStateManager?.CompleteItem("system.init", "系统初始化完成");
|
||||
|
||||
// 报告 Ready 状态,启动器可以安全关闭 Splash 窗口
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "就绪");
|
||||
|
||||
// 停止加载状态上报
|
||||
_loadingStateReporter?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private MainWindow GetOrCreateMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
@@ -999,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();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
<!-- Launcher 引用已移除 - Launcher 现在是独立应用 -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -76,20 +76,31 @@
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageReference Include="log4net" Version="3.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
|
||||
|
||||
<!-- 生成版本信息文件 -->
|
||||
<Target Name="GenerateVersionFile" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<VersionFilePath>$(OutDir)version.json</VersionFilePath>
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
<!-- 发布时也生成版本信息文件 -->
|
||||
<Target Name="GenerateVersionFilePublish" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<VersionFilePath>$(PublishDir)version.json</VersionFilePath>
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -462,6 +462,12 @@
|
||||
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
|
||||
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
|
||||
"settings.update.status_downloading": "Downloading installer...",
|
||||
"settings.update.status_downloading_delta": "Downloading incremental update...",
|
||||
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
|
||||
"settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.",
|
||||
"settings.update.type_label": "Update Type",
|
||||
"settings.update.type_delta": "Incremental Update",
|
||||
"settings.update.type_full": "Full Installer",
|
||||
"settings.update.status_download_failed_format": "Download failed: {0}",
|
||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||
|
||||
@@ -457,6 +457,12 @@
|
||||
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
|
||||
"settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。",
|
||||
"settings.update.status_downloading": "正在下载安装包...",
|
||||
"settings.update.status_downloading_delta": "正在下载增量更新包...",
|
||||
"settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。",
|
||||
"settings.update.status_delta_launch_failed": "启动增量更新程序失败。",
|
||||
"settings.update.type_label": "更新类型",
|
||||
"settings.update.type_delta": "增量更新",
|
||||
"settings.update.type_full": "完整安装包",
|
||||
"settings.update.status_download_failed_format": "下载失败:{0}",
|
||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
|
||||
public string UpdateDownloadSource { get; set; } = "github";
|
||||
public string UpdateDownloadSource { get; set; } = "pdc";
|
||||
|
||||
public int UpdateDownloadThreads { get; set; } = 4;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -32,11 +33,26 @@ public sealed class Program
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
if (activationAcknowledged)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
21
LanMountainDesktop/Properties/launchSettings.json
Normal file
21
LanMountainDesktop/Properties/launchSettings.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"LanMountainDesktop (Direct)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"LanMountainDesktop (via Launcher)": {
|
||||
"commandName": "Executable",
|
||||
"executablePath": "$(SolutionDir)LanMountainDesktop.Launcher\\bin\\$(Configuration)\\net10.0\\LanMountainDesktop.Launcher.exe",
|
||||
"commandLineArgs": "launch",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,12 +100,15 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -122,13 +125,16 @@ public static class AppRestartService
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -145,11 +151,61 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
|
||||
@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
|
||||
129
LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
Normal file
129
LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
|
||||
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// </summary>
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
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>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 连接到 Launcher 的 IPC 服务端
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipeClient = new NamedPipeClientStream(
|
||||
".",
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.Out);
|
||||
|
||||
await _pipeClient.ConnectAsync(5000, cancellationToken);
|
||||
_isConnected = true;
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Launcher 可能没有启动 IPC 服务端,这是正常的
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to connect to Launcher IPC: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告启动进度(在同一连接上可多次调用)
|
||||
/// </summary>
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (!_isConnected || _pipeClient?.IsConnected != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// 长度前缀协议:[4字节长度][消息正文]
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
|
||||
|
||||
// 加锁保证单条消息的长度前缀和正文原子写入
|
||||
lock (_writeLock)
|
||||
{
|
||||
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
_pipeClient.Write(payload, 0, payload.Length);
|
||||
_pipeClient.Flush();
|
||||
}
|
||||
|
||||
// 将同步写入包装为已完成的 Task
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 管道断开
|
||||
_isConnected = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
// 优先检查环境变量
|
||||
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()
|
||||
{
|
||||
_isConnected = false;
|
||||
_pipeClient?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class PluginsInstallHelperClient
|
||||
internal sealed class LauncherClient
|
||||
{
|
||||
private const int UserCanceledUacErrorCode = 1223;
|
||||
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
||||
|
||||
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
|
||||
public async Task<LauncherInstallResult> InstallPackageAsync(
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -25,19 +25,19 @@ internal sealed class PluginsInstallHelperClient
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
"Elevated helper install is only supported on Windows.");
|
||||
}
|
||||
|
||||
var helperPath = ResolveHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
var launcherPath = ResolveLauncherPath();
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"Plugins install helper was not found at '{helperPath}'.");
|
||||
$"Launcher executable was not found at '{launcherPath}'.");
|
||||
}
|
||||
|
||||
var resultPath = Path.Combine(
|
||||
@@ -50,38 +50,38 @@ internal sealed class PluginsInstallHelperClient
|
||||
|
||||
try
|
||||
{
|
||||
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
|
||||
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
|
||||
if (process is null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
|
||||
return new LauncherInstallResult(false, null, "Failed to start launcher process.");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||
if (result is not null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||
return new LauncherInstallResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||
}
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
"Plugins install helper exited without producing a result file.");
|
||||
"Launcher exited without producing a result file.");
|
||||
}
|
||||
|
||||
return new PluginsInstallHelperResult(
|
||||
return new LauncherInstallResult(
|
||||
false,
|
||||
null,
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Plugins install helper exited with code {0}.",
|
||||
"Launcher exited with code {0}.",
|
||||
process.ExitCode));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
|
||||
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -89,18 +89,18 @@ internal sealed class PluginsInstallHelperClient
|
||||
}
|
||||
}
|
||||
|
||||
private static Process? StartHelperProcess(
|
||||
string helperPath,
|
||||
private static Process? StartLauncherProcess(
|
||||
string launcherPath,
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
string resultPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
FileName = launcherPath,
|
||||
Verb = "runas",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
|
||||
@@ -120,9 +120,9 @@ internal sealed class PluginsInstallHelperClient
|
||||
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static string ResolveHelperPath()
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
@@ -180,7 +180,7 @@ internal sealed class PluginsInstallHelperClient
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PluginsInstallHelperResult(
|
||||
internal sealed record LauncherInstallResult(
|
||||
bool Success,
|
||||
string? InstalledPackagePath,
|
||||
string? ErrorMessage);
|
||||
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal file
380
LanMountainDesktop/Services/Loading/LoadingStateManager.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System.Collections.Concurrent;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态管理器 - 管理所有加载项的状态
|
||||
/// </summary>
|
||||
public class LoadingStateManager : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, LoadingItem> _items = new();
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _startTimes = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件
|
||||
/// </summary>
|
||||
public event EventHandler<OverallProgressChangedEventArgs>? OverallProgressChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage CurrentStage { get; private set; } = StartupStage.Initializing;
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比
|
||||
/// </summary>
|
||||
public int OverallProgressPercent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否正在加载
|
||||
/// </summary>
|
||||
public bool IsLoading => _items.Values.Any(i => i.State == LoadingState.InProgress);
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
public bool HasErrors => _items.Values.Any(i => i.State == LoadingState.Failed);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有加载项
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<LoadingItem> GetAllItems() => _items.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 获取活动的加载项
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<LoadingItem> GetActiveItems() =>
|
||||
_items.Values.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 注册加载项
|
||||
/// </summary>
|
||||
public LoadingItem RegisterItem(
|
||||
string id,
|
||||
LoadingItemType type,
|
||||
string name,
|
||||
string? description = null,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
{
|
||||
var item = new LoadingItem
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
Name = name,
|
||||
Description = description,
|
||||
State = LoadingState.Pending,
|
||||
ProgressPercent = 0,
|
||||
Metadata = metadata,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = item;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = item,
|
||||
PreviousState = null,
|
||||
CurrentState = item.State
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开始加载
|
||||
/// </summary>
|
||||
public void StartItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes[id] = startTime;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.InProgress,
|
||||
StartTime = startTime,
|
||||
Message = message ?? $"正在加载 {item.Name}...",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度
|
||||
/// </summary>
|
||||
public void UpdateProgress(string id, int percent, string? message = null, int? estimatedRemainingSeconds = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
ProgressPercent = Math.Clamp(percent, 0, 100),
|
||||
Message = message ?? item.Message,
|
||||
EstimatedRemainingSeconds = estimatedRemainingSeconds,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = item.State,
|
||||
CurrentState = updatedItem.State,
|
||||
IsProgressUpdate = true
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成加载
|
||||
/// </summary>
|
||||
public void CompleteItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Completed,
|
||||
ProgressPercent = 100,
|
||||
EndTime = endTime,
|
||||
Message = message ?? $"{item.Name} 加载完成",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记失败
|
||||
/// </summary>
|
||||
public void FailItem(string id, string errorMessage, string? details = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var fullErrorMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
: $"{errorMessage}: {details}";
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Failed,
|
||||
ErrorMessage = fullErrorMessage,
|
||||
EndTime = endTime,
|
||||
Message = $"{item.Name} 加载失败",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记超时
|
||||
/// </summary>
|
||||
public void TimeoutItem(string id, string? message = null)
|
||||
{
|
||||
if (!_items.TryGetValue(id, out var item))
|
||||
return;
|
||||
|
||||
var previousState = item.State;
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
_startTimes.TryRemove(id, out _);
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
State = LoadingState.Timeout,
|
||||
EndTime = endTime,
|
||||
Message = message ?? $"{item.Name} 加载超时",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_items[id] = updatedItem;
|
||||
|
||||
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
|
||||
{
|
||||
Item = updatedItem,
|
||||
PreviousState = previousState,
|
||||
CurrentState = updatedItem.State
|
||||
});
|
||||
|
||||
UpdateOverallProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置当前启动阶段
|
||||
/// </summary>
|
||||
public void SetStage(StartupStage stage, string? message = null)
|
||||
{
|
||||
CurrentStage = stage;
|
||||
|
||||
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
|
||||
{
|
||||
Stage = stage,
|
||||
OverallProgressPercent = OverallProgressPercent,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新整体进度
|
||||
/// </summary>
|
||||
private void UpdateOverallProgress()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _items.Values.ToList();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
OverallProgressPercent = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算加权进度
|
||||
var totalWeight = items.Count;
|
||||
var completedWeight = items.Count(i => i.State == LoadingState.Completed);
|
||||
var inProgressWeight = items
|
||||
.Where(i => i.State == LoadingState.InProgress)
|
||||
.Sum(i => i.ProgressPercent / 100.0);
|
||||
|
||||
var progress = (int)((completedWeight + inProgressWeight) / totalWeight * 100);
|
||||
OverallProgressPercent = Math.Clamp(progress, 0, 100);
|
||||
|
||||
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
|
||||
{
|
||||
Stage = CurrentStage,
|
||||
OverallProgressPercent = OverallProgressPercent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取加载状态消息
|
||||
/// </summary>
|
||||
public LoadingStateMessage GetLoadingStateMessage()
|
||||
{
|
||||
var items = _items.Values.ToList();
|
||||
var activeItems = items.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
|
||||
var errorItems = items.Where(i => i.State == LoadingState.Failed).ToList();
|
||||
|
||||
return new LoadingStateMessage
|
||||
{
|
||||
Stage = CurrentStage,
|
||||
OverallProgressPercent = OverallProgressPercent,
|
||||
ActiveItems = activeItems,
|
||||
CompletedCount = items.Count(i => i.State == LoadingState.Completed),
|
||||
TotalCount = items.Count,
|
||||
HasErrors = errorItems.Any(),
|
||||
ErrorMessages = errorItems.Select(i => $"{i.Name}: {i.ErrorMessage}").ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有加载项
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_items.Clear();
|
||||
_startTimes.Clear();
|
||||
OverallProgressPercent = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查超时项
|
||||
/// </summary>
|
||||
public void CheckTimeouts(TimeSpan timeout)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeoutItems = _items.Values
|
||||
.Where(i => i.State == LoadingState.InProgress && i.StartTime.HasValue)
|
||||
.Where(i => now - i.StartTime.Value > timeout)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in timeoutItems)
|
||||
{
|
||||
TimeoutItem(item.Id, $"{item.Name} 加载超时(超过 {timeout.TotalSeconds} 秒)");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_items.Clear();
|
||||
_startTimes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态变更事件参数
|
||||
/// </summary>
|
||||
public class LoadingStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public required LoadingItem Item { get; init; }
|
||||
public LoadingState? PreviousState { get; init; }
|
||||
public required LoadingState CurrentState { get; init; }
|
||||
public bool IsProgressUpdate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件参数
|
||||
/// </summary>
|
||||
public class OverallProgressChangedEventArgs : EventArgs
|
||||
{
|
||||
public StartupStage Stage { get; init; }
|
||||
public int OverallProgressPercent { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal file
360
LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
Normal file
@@ -0,0 +1,360 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态上报器 - 将加载状态实时上报给 Launcher
|
||||
/// </summary>
|
||||
public class LoadingStateReporter : IDisposable
|
||||
{
|
||||
private readonly LoadingStateManager _manager;
|
||||
private readonly LauncherIpcClient? _ipcClient;
|
||||
private readonly System.Timers.Timer _reportTimer;
|
||||
private readonly object _lock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// 上报间隔(毫秒)
|
||||
/// </summary>
|
||||
public int ReportIntervalMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用批量上报优化
|
||||
/// </summary>
|
||||
public bool EnableBatching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 最小上报间隔(毫秒),用于限制高频更新
|
||||
/// </summary>
|
||||
public int MinReportIntervalMs { get; set; } = 50;
|
||||
|
||||
private DateTimeOffset _lastReportTime = DateTimeOffset.MinValue;
|
||||
private DetailedProgressMessage? _pendingMessage;
|
||||
private bool _hasPendingMessage;
|
||||
|
||||
public LoadingStateReporter(
|
||||
LoadingStateManager manager,
|
||||
LauncherIpcClient? ipcClient = null)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_ipcClient = ipcClient;
|
||||
|
||||
// 创建定时上报定时器
|
||||
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
||||
_reportTimer.Elapsed += OnReportTimerElapsed;
|
||||
_reportTimer.AutoReset = true;
|
||||
|
||||
// 订阅状态变更事件
|
||||
_manager.StateChanged += OnStateChanged;
|
||||
_manager.OverallProgressChanged += OnOverallProgressChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动上报
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_reportTimer.Start();
|
||||
AppLogger.Info("LoadingStateReporter", "Loading state reporter started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止上报
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_reportTimer.Stop();
|
||||
|
||||
// 发送任何待处理的消息
|
||||
FlushPendingMessage();
|
||||
|
||||
AppLogger.Info("LoadingStateReporter", "Loading state reporter stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 立即上报当前状态
|
||||
/// </summary>
|
||||
public async Task ReportImmediatelyAsync()
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var message = CreateDetailedProgressMessage();
|
||||
await SendMessageAsync(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报单个加载项的进度
|
||||
/// </summary>
|
||||
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item == null) return;
|
||||
|
||||
var updatedItem = item with
|
||||
{
|
||||
ProgressPercent = percent,
|
||||
Message = message ?? item.Message,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
CurrentItem = updatedItem,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message,
|
||||
IsMajorUpdate = false
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报阶段变更
|
||||
/// </summary>
|
||||
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message ?? $"进入阶段: {stage}",
|
||||
IsMajorUpdate = true
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报错误
|
||||
/// </summary>
|
||||
public async Task ReportErrorAsync(string errorMessage, string? details = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
|
||||
var fullMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
: $"{errorMessage}: {details}";
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = fullMessage,
|
||||
IsMajorUpdate = true
|
||||
};
|
||||
|
||||
await SendMessageAsync(progressMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件处理
|
||||
/// </summary>
|
||||
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
// 重要状态变更立即上报
|
||||
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Timeout)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReportImmediatelyAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to report state change: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其他状态变更标记为待处理
|
||||
QueueMessage(CreateDetailedProgressMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度变更事件处理
|
||||
/// </summary>
|
||||
private void OnOverallProgressChanged(object? sender, OverallProgressChangedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
QueueMessage(CreateDetailedProgressMessage(e.Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时上报处理
|
||||
/// </summary>
|
||||
private void OnReportTimerElapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
FlushPendingMessage();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将消息加入待处理队列
|
||||
/// </summary>
|
||||
private void QueueMessage(DetailedProgressMessage message)
|
||||
{
|
||||
if (!EnableBatching)
|
||||
{
|
||||
// 如果不启用批量,立即发送
|
||||
_ = Task.Run(async () => await SendMessageAsync(message));
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_pendingMessage = message;
|
||||
_hasPendingMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新待处理消息
|
||||
/// </summary>
|
||||
private void FlushPendingMessage()
|
||||
{
|
||||
DetailedProgressMessage? message;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_hasPendingMessage) return;
|
||||
|
||||
message = _pendingMessage;
|
||||
_pendingMessage = null;
|
||||
_hasPendingMessage = false;
|
||||
}
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to flush pending message: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建详细的进度消息
|
||||
/// </summary>
|
||||
private DetailedProgressMessage CreateDetailedProgressMessage(string? message = null)
|
||||
{
|
||||
var activeItems = _manager.GetActiveItems().ToList();
|
||||
var currentItem = activeItems.FirstOrDefault();
|
||||
|
||||
return new DetailedProgressMessage
|
||||
{
|
||||
Stage = _manager.CurrentStage,
|
||||
ProgressPercent = _manager.OverallProgressPercent,
|
||||
CurrentItem = currentItem,
|
||||
AllItems = _manager.GetAllItems().ToList(),
|
||||
Message = message ?? currentItem?.Message,
|
||||
IsMajorUpdate = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送消息
|
||||
/// </summary>
|
||||
private async Task SendMessageAsync(DetailedProgressMessage message)
|
||||
{
|
||||
if (_ipcClient == null) return;
|
||||
|
||||
// 检查最小上报间隔
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var elapsed = now - _lastReportTime;
|
||||
if (elapsed.TotalMilliseconds < MinReportIntervalMs)
|
||||
{
|
||||
await Task.Delay(MinReportIntervalMs - (int)elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 转换为 StartupProgressMessage 以保持兼容性
|
||||
var baseMessage = new StartupProgressMessage
|
||||
{
|
||||
Stage = message.Stage,
|
||||
ProgressPercent = message.ProgressPercent,
|
||||
Message = FormatMessage(message),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _ipcClient.ReportProgressAsync(baseMessage);
|
||||
_lastReportTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingStateReporter", $"Failed to send message: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化消息
|
||||
/// </summary>
|
||||
private string FormatMessage(DetailedProgressMessage message)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (message.CurrentItem != null)
|
||||
{
|
||||
parts.Add($"[{message.CurrentItem.Type}] {message.CurrentItem.Name}");
|
||||
|
||||
if (message.CurrentItem.ProgressPercent > 0)
|
||||
{
|
||||
parts.Add($"{message.CurrentItem.ProgressPercent}%");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message.Message))
|
||||
{
|
||||
parts.Add(message.Message);
|
||||
}
|
||||
|
||||
var completedCount = message.AllItems?.Count(i => i.State == LoadingState.Completed) ?? 0;
|
||||
var totalCount = message.AllItems?.Count ?? 0;
|
||||
|
||||
if (totalCount > 0)
|
||||
{
|
||||
parts.Add($"({completedCount}/{totalCount})");
|
||||
}
|
||||
|
||||
return string.Join(" - ", parts);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
Stop();
|
||||
|
||||
_reportTimer.Elapsed -= OnReportTimerElapsed;
|
||||
_reportTimer.Dispose();
|
||||
|
||||
_manager.StateChanged -= OnStateChanged;
|
||||
_manager.OverallProgressChanged -= OnOverallProgressChanged;
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal file
201
LanMountainDesktop/Services/Loading/LoadingStateUsageExample.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态管理使用示例
|
||||
/// </summary>
|
||||
public static class LoadingStateUsageExample
|
||||
{
|
||||
/// <summary>
|
||||
/// 示例:插件加载
|
||||
/// </summary>
|
||||
public static async Task LoadPluginsExample(LoadingStateManager manager)
|
||||
{
|
||||
// 注册插件加载项
|
||||
var pluginItem = manager.RegisterItem(
|
||||
"plugins.core",
|
||||
LoadingItemType.Plugin,
|
||||
"核心插件",
|
||||
"加载系统核心插件",
|
||||
new Dictionary<string, string> { { "version", "1.0.0" } });
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem("plugins.core", "正在下载插件...");
|
||||
|
||||
try
|
||||
{
|
||||
// 模拟下载进度
|
||||
for (int i = 0; i <= 100; i += 10)
|
||||
{
|
||||
manager.UpdateProgress(
|
||||
"plugins.core",
|
||||
i,
|
||||
$"正在下载... {i}%",
|
||||
estimatedRemainingSeconds: (100 - i) / 10);
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
// 完成加载
|
||||
manager.CompleteItem("plugins.core", "核心插件加载完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 标记失败
|
||||
manager.FailItem("plugins.core", "插件加载失败", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:组件加载
|
||||
/// </summary>
|
||||
public static async Task LoadComponentsExample(LoadingStateManager manager)
|
||||
{
|
||||
var components = new[]
|
||||
{
|
||||
("comp.weather", "天气组件"),
|
||||
("comp.clock", "时钟组件"),
|
||||
("comp.calendar", "日历组件")
|
||||
};
|
||||
|
||||
foreach (var (id, name) in components)
|
||||
{
|
||||
// 注册组件
|
||||
manager.RegisterItem(id, LoadingItemType.Component, name);
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem(id, $"正在加载 {name}...");
|
||||
|
||||
// 模拟加载过程
|
||||
for (int i = 0; i <= 100; i += 20)
|
||||
{
|
||||
manager.UpdateProgress(id, i);
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
// 完成
|
||||
manager.CompleteItem(id, $"{name} 加载完成");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:网络资源加载
|
||||
/// </summary>
|
||||
public static async Task LoadNetworkResourcesExample(LoadingStateManager manager)
|
||||
{
|
||||
// 注册网络加载项
|
||||
manager.RegisterItem(
|
||||
"network.config",
|
||||
LoadingItemType.Network,
|
||||
"配置数据",
|
||||
"从服务器获取最新配置");
|
||||
|
||||
manager.StartItem("network.config", "正在连接服务器...");
|
||||
|
||||
try
|
||||
{
|
||||
// 模拟网络请求
|
||||
await Task.Delay(1000);
|
||||
|
||||
manager.UpdateProgress("network.config", 50, "正在下载数据...");
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
manager.CompleteItem("network.config", "配置数据已更新");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
manager.FailItem("network.config", "网络请求失败", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:带超时的加载
|
||||
/// </summary>
|
||||
public static async Task LoadWithTimeoutExample(
|
||||
LoadingStateManager manager,
|
||||
LoadingTimeoutHandler timeoutHandler)
|
||||
{
|
||||
// 设置超时时间为 10 秒
|
||||
timeoutHandler.SetItemTimeout("data.heavy", TimeSpan.FromSeconds(10));
|
||||
|
||||
// 注册加载项
|
||||
manager.RegisterItem(
|
||||
"data.heavy",
|
||||
LoadingItemType.Data,
|
||||
"大数据处理",
|
||||
"处理大量数据,可能需要较长时间");
|
||||
|
||||
// 订阅超时事件
|
||||
timeoutHandler.ItemTimeout += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"加载项 '{e.ItemName}' 超时!");
|
||||
};
|
||||
|
||||
timeoutHandler.ItemRetry += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"正在重试 '{e.ItemName}' ({e.RetryCount}/{e.MaxRetryCount})...");
|
||||
};
|
||||
|
||||
// 开始加载
|
||||
manager.StartItem("data.heavy", "正在处理数据...");
|
||||
|
||||
// 模拟长时间操作
|
||||
await Task.Delay(15000);
|
||||
|
||||
// 完成
|
||||
manager.CompleteItem("data.heavy", "数据处理完成");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例:完整启动流程
|
||||
/// </summary>
|
||||
public static async Task FullStartupExample(
|
||||
LoadingStateManager manager,
|
||||
LoadingStateReporter reporter,
|
||||
LoadingTimeoutHandler timeoutHandler)
|
||||
{
|
||||
// 启动超时处理器
|
||||
timeoutHandler.Start();
|
||||
|
||||
// 设置阶段
|
||||
manager.SetStage(StartupStage.Initializing, "开始初始化...");
|
||||
|
||||
// 1. 系统初始化
|
||||
manager.RegisterItem("system.init", LoadingItemType.System, "系统初始化");
|
||||
manager.StartItem("system.init");
|
||||
await Task.Delay(500);
|
||||
manager.CompleteItem("system.init");
|
||||
|
||||
// 2. 加载设置
|
||||
manager.SetStage(StartupStage.LoadingSettings, "正在加载设置...");
|
||||
manager.RegisterItem("settings.load", LoadingItemType.Settings, "用户设置");
|
||||
manager.StartItem("settings.load");
|
||||
await Task.Delay(800);
|
||||
manager.CompleteItem("settings.load");
|
||||
|
||||
// 3. 加载插件
|
||||
manager.SetStage(StartupStage.LoadingPlugins, "正在加载插件...");
|
||||
await LoadPluginsExample(manager);
|
||||
|
||||
// 4. 加载组件
|
||||
await LoadComponentsExample(manager);
|
||||
|
||||
// 5. 加载网络资源
|
||||
await LoadNetworkResourcesExample(manager);
|
||||
|
||||
// 6. 初始化界面
|
||||
manager.SetStage(StartupStage.InitializingUI, "正在初始化界面...");
|
||||
manager.RegisterItem("ui.init", LoadingItemType.System, "界面初始化");
|
||||
manager.StartItem("ui.init");
|
||||
await Task.Delay(600);
|
||||
manager.CompleteItem("ui.init");
|
||||
|
||||
// 完成
|
||||
manager.SetStage(StartupStage.Ready, "加载完成");
|
||||
|
||||
// 停止超时处理器
|
||||
timeoutHandler.Stop();
|
||||
}
|
||||
}
|
||||
275
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
275
LanMountainDesktop/Services/Loading/LoadingTimeoutHandler.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// 加载超时处理器 - 监控加载项超时并执行相应处理
|
||||
/// </summary>
|
||||
public class LoadingTimeoutHandler : IDisposable
|
||||
{
|
||||
private readonly LoadingStateManager _manager;
|
||||
private readonly System.Timers.Timer _checkTimer;
|
||||
private readonly Dictionary<string, TimeSpan> _itemTimeouts = new();
|
||||
private readonly Dictionary<string, int> _retryCounts = new();
|
||||
private readonly object _lock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// 默认超时时间
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// 最大重试次数
|
||||
/// </summary>
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 检查间隔
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// 超时事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingTimeoutEventArgs>? ItemTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// 重试事件
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingRetryEventArgs>? ItemRetry;
|
||||
|
||||
/// <summary>
|
||||
/// 最终失败事件(超过最大重试次数)
|
||||
/// </summary>
|
||||
public event EventHandler<LoadingTimeoutEventArgs>? ItemFailed;
|
||||
|
||||
public LoadingTimeoutHandler(LoadingStateManager manager)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
|
||||
_checkTimer = new System.Timers.Timer(CheckInterval.TotalMilliseconds);
|
||||
_checkTimer.Elapsed += OnCheckTimerElapsed;
|
||||
_checkTimer.AutoReset = true;
|
||||
|
||||
// 订阅状态变更事件
|
||||
_manager.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动监控
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_checkTimer.Start();
|
||||
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止监控
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_checkTimer.Stop();
|
||||
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为特定加载项设置超时
|
||||
/// </summary>
|
||||
public void SetItemTimeout(string itemId, TimeSpan timeout)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_itemTimeouts[itemId] = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取加载项的超时时间
|
||||
/// </summary>
|
||||
public TimeSpan GetItemTimeout(string itemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _itemTimeouts.TryGetValue(itemId, out var timeout) ? timeout : DefaultTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置重试计数
|
||||
/// </summary>
|
||||
public void ResetRetryCount(string itemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_retryCounts[itemId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时检查超时
|
||||
/// </summary>
|
||||
private void OnCheckTimerElapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var activeItems = _manager.GetActiveItems().ToList();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var item in activeItems)
|
||||
{
|
||||
if (!item.StartTime.HasValue) continue;
|
||||
|
||||
var timeout = GetItemTimeout(item.Id);
|
||||
var elapsed = now - item.StartTime.Value;
|
||||
|
||||
if (elapsed > timeout)
|
||||
{
|
||||
HandleTimeout(item.Id, elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LoadingTimeoutHandler", $"Error checking timeouts: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理超时
|
||||
/// </summary>
|
||||
private void HandleTimeout(string itemId, TimeSpan elapsed)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var retryCount = _retryCounts.GetValueOrDefault(itemId, 0);
|
||||
|
||||
if (retryCount < MaxRetryCount)
|
||||
{
|
||||
// 重试
|
||||
_retryCounts[itemId] = retryCount + 1;
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item != null)
|
||||
{
|
||||
AppLogger.Warn("LoadingTimeoutHandler",
|
||||
$"Item '{item.Name}' timed out after {elapsed.TotalSeconds}s, retrying ({retryCount + 1}/{MaxRetryCount})...");
|
||||
|
||||
ItemRetry?.Invoke(this, new LoadingRetryEventArgs
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = item.Name,
|
||||
RetryCount = retryCount + 1,
|
||||
MaxRetryCount = MaxRetryCount,
|
||||
ElapsedTime = elapsed
|
||||
});
|
||||
|
||||
// 重新启动该项
|
||||
_manager.StartItem(itemId, $"第 {retryCount + 1} 次重试...");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 最终失败
|
||||
_retryCounts.Remove(itemId);
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item != null)
|
||||
{
|
||||
AppLogger.Error("LoadingTimeoutHandler",
|
||||
$"Item '{item.Name}' failed after {MaxRetryCount} retries ({elapsed.TotalSeconds}s)");
|
||||
|
||||
var args = new LoadingTimeoutEventArgs
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = item.Name,
|
||||
ElapsedTime = elapsed,
|
||||
RetryCount = MaxRetryCount,
|
||||
IsFinalFailure = true
|
||||
};
|
||||
|
||||
ItemTimeout?.Invoke(this, args);
|
||||
ItemFailed?.Invoke(this, args);
|
||||
|
||||
// 标记为失败
|
||||
_manager.FailItem(itemId,
|
||||
$"加载超时(超过 {elapsed.TotalSeconds:F0} 秒)",
|
||||
$"已重试 {MaxRetryCount} 次但仍失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更事件处理
|
||||
/// </summary>
|
||||
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
|
||||
{
|
||||
// 当项完成或失败时,清除重试计数
|
||||
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Cancelled)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_retryCounts.Remove(e.Item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// 当项开始时,如果是第一次开始,初始化重试计数
|
||||
if (e.CurrentState == LoadingState.InProgress &&
|
||||
(e.PreviousState == null || e.PreviousState == LoadingState.Pending))
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_retryCounts.ContainsKey(e.Item.Id))
|
||||
{
|
||||
_retryCounts[e.Item.Id] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
Stop();
|
||||
|
||||
_checkTimer.Elapsed -= OnCheckTimerElapsed;
|
||||
_checkTimer.Dispose();
|
||||
|
||||
_manager.StateChanged -= OnStateChanged;
|
||||
|
||||
_itemTimeouts.Clear();
|
||||
_retryCounts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载超时事件参数
|
||||
/// </summary>
|
||||
public class LoadingTimeoutEventArgs : EventArgs
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string ItemName { get; init; }
|
||||
public required TimeSpan ElapsedTime { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public bool IsFinalFailure { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载重试事件参数
|
||||
/// </summary>
|
||||
public class LoadingRetryEventArgs : EventArgs
|
||||
{
|
||||
public required string ItemId { get; init; }
|
||||
public required string ItemName { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public required int MaxRetryCount { get; init; }
|
||||
public required TimeSpan ElapsedTime { get; init; }
|
||||
}
|
||||
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
@@ -0,0 +1,464 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort PDC client that maps PDC responses to the existing update result model.
|
||||
/// This keeps launcher update contracts stable while allowing a gradual migration.
|
||||
/// </summary>
|
||||
public sealed class PdcReleaseUpdateService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public PdcReleaseUpdateService(HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
var endpoint = ResolveEndpoint();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC endpoint is not configured.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
|
||||
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var channelId = ResolveChannelId(metadata, includePrerelease);
|
||||
if (string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
channelId = includePrerelease ? "preview" : "stable";
|
||||
}
|
||||
|
||||
var latestUrl = BuildUri(
|
||||
endpoint,
|
||||
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
||||
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC latest distribution version is invalid.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var distributionId = ReadString(latestNode, "distributionId");
|
||||
if (string.IsNullOrWhiteSpace(distributionId))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC latest distribution id is missing.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var hasUpdate = latestVersion > normalizedCurrentVersion;
|
||||
if (!isForce && !hasUpdate)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: false);
|
||||
}
|
||||
|
||||
var subChannel = ResolveSubChannel();
|
||||
var distributionUrl = BuildUri(
|
||||
endpoint,
|
||||
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
|
||||
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var assets = ResolveAssets(distributionNode);
|
||||
if (assets.Count == 0)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var release = new GitHubReleaseInfo(
|
||||
TagName: $"v{latestVersionText}",
|
||||
Name: $"PDC Distribution {latestVersionText}",
|
||||
IsPrerelease: includePrerelease,
|
||||
IsDraft: false,
|
||||
PublishedAt: DateTimeOffset.UtcNow,
|
||||
Assets: assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PDC request failed: {ex.Message}",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = ResolveToken();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.Clone();
|
||||
}
|
||||
|
||||
return root.Clone();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
|
||||
{
|
||||
var assets = new List<GitHubReleaseAsset>();
|
||||
if (distributionNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return assets;
|
||||
}
|
||||
|
||||
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
|
||||
assetsNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var assetNode in assetsNode.EnumerateArray())
|
||||
{
|
||||
if (assetNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(assetNode, "name");
|
||||
var url = ReadString(assetNode, "url") ??
|
||||
ReadString(assetNode, "downloadUrl") ??
|
||||
ReadString(assetNode, "browserDownloadUrl");
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var size = ReadInt64(assetNode, "size") ?? 0L;
|
||||
var sha256 = ReadString(assetNode, "sha256");
|
||||
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.Count > 0)
|
||||
{
|
||||
return assets;
|
||||
}
|
||||
|
||||
// Field-level fallback for service-side URL projection.
|
||||
var manifestUrl = ReadString(distributionNode, "manifestUrl")
|
||||
?? ReadString(distributionNode, "fileMapUrl");
|
||||
var signatureUrl = ReadString(distributionNode, "signatureUrl")
|
||||
?? ReadString(distributionNode, "fileMapSignatureUrl");
|
||||
var archiveUrl = ReadString(distributionNode, "archiveUrl")
|
||||
?? ReadString(distributionNode, "updateArchiveUrl")
|
||||
?? ReadString(distributionNode, "payloadUrl");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifestUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signatureUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(archiveUrl))
|
||||
{
|
||||
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
|
||||
{
|
||||
if (metadataNode.ValueKind != JsonValueKind.Object ||
|
||||
!metadataNode.TryGetProperty("channels", out var channelsNode))
|
||||
{
|
||||
return includePrerelease ? "preview" : "stable";
|
||||
}
|
||||
|
||||
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
|
||||
if (channelsNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return defaultChannelId;
|
||||
}
|
||||
|
||||
string? matchedPreview = null;
|
||||
string? matchedStable = null;
|
||||
|
||||
foreach (var channel in channelsNode.EnumerateObject())
|
||||
{
|
||||
var name = ReadString(channel.Value, "name") ?? channel.Name;
|
||||
if (string.IsNullOrWhiteSpace(matchedPreview) &&
|
||||
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
matchedPreview = channel.Name;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(matchedStable) &&
|
||||
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
matchedStable = channel.Name;
|
||||
}
|
||||
}
|
||||
|
||||
if (includePrerelease)
|
||||
{
|
||||
return matchedPreview ?? defaultChannelId ?? "preview";
|
||||
}
|
||||
|
||||
return matchedStable ?? defaultChannelId ?? "stable";
|
||||
}
|
||||
|
||||
private static string ResolveSubChannel()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
|
||||
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}_{arch}_release_folderClassic";
|
||||
}
|
||||
|
||||
private static string? ResolveEndpoint()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
|
||||
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string? ResolveToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
|
||||
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
|
||||
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
|
||||
}
|
||||
|
||||
private static string BuildUri(string endpoint, string relativePath)
|
||||
{
|
||||
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement node, string propertyName)
|
||||
{
|
||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: value.ToString();
|
||||
}
|
||||
|
||||
private static long? ReadInt64(JsonElement node, string propertyName)
|
||||
{
|
||||
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.TryGetInt64(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
var text = value.ToString();
|
||||
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().TrimStart('v', 'V');
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(normalized, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = NormalizeVersion(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version NormalizeVersion(Version version)
|
||||
{
|
||||
var major = Math.Max(0, version.Major);
|
||||
var minor = Math.Max(0, version.Minor);
|
||||
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
||||
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
||||
return revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
}
|
||||
|
||||
private static string FormatVersionText(Version version)
|
||||
{
|
||||
return version.Revision > 0
|
||||
? version.ToString(4)
|
||||
: version.ToString(3);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using PostHog;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
{
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||
private const string PostHogHostUrl = "https://us.i.posthog.com";
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly HttpClient _httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
private readonly Queue<TelemetryEvent> _eventQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
private readonly PostHogClient _client;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private Timer? _flushTimer;
|
||||
private bool _isInitialized;
|
||||
@@ -39,6 +33,14 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
|
||||
_client = new PostHogClient(new PostHogOptions
|
||||
{
|
||||
ProjectApiKey = PostHogApiKey,
|
||||
HostUrl = new Uri(PostHogHostUrl),
|
||||
FlushAt = 20,
|
||||
FlushInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
}
|
||||
|
||||
public bool IsUsageEnabled => _isUsageEnabled;
|
||||
@@ -56,7 +58,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
RefreshEnabledState(forceSessionStart: true);
|
||||
|
||||
_flushTimer = new Timer(
|
||||
_ => FlushEvents(),
|
||||
_ => _ = _client.FlushAsync(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
@@ -88,14 +90,12 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
|
||||
_isUsageEnabled = false;
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
EndSession(source, isRestart);
|
||||
}
|
||||
|
||||
FlushEvents();
|
||||
_ = _client.FlushAsync();
|
||||
AppLogger.Info(
|
||||
"PostHogUsage",
|
||||
$"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
|
||||
@@ -291,16 +291,13 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
_flushTimer?.Dispose();
|
||||
_settingsService.Changed -= OnSettingsChanged;
|
||||
Shutdown(isRestart: false, source: "Dispose");
|
||||
FlushEvents();
|
||||
_cts.Cancel();
|
||||
_client.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBaselineEventSent()
|
||||
@@ -313,66 +310,35 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (SendBaselineEventToPostHog(identity.InstallId, now))
|
||||
var distinctId = identity.InstallId;
|
||||
var personProps = new Dictionary<string, object?>
|
||||
{
|
||||
identity.MarkBaselineReported();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = "app_first_launch",
|
||||
["distinct_id"] = installId,
|
||||
["timestamp"] = timestamp.ToString("o"),
|
||||
["properties"] = new Dictionary<string, object?>
|
||||
{
|
||||
["install_id"] = installId,
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||
["launch_time_utc"] = timestamp.ToString("o")
|
||||
}
|
||||
["install_id"] = identity.InstallId,
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
_ = _client.IdentifyAsync(distinctId, personProps, null, _cts.Token);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
_client.Capture(
|
||||
distinctId,
|
||||
"app_first_launch",
|
||||
personProps,
|
||||
groups: null,
|
||||
sendFeatureFlags: false);
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
|
||||
return true;
|
||||
_ = _client.FlushAsync();
|
||||
identity.MarkBaselineReported();
|
||||
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event via SDK.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,137 +445,60 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var eventData = new TelemetryEvent(
|
||||
eventName,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
TelemetryIdentityService.Instance.InstallId,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
_sessionId,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
DateTimeOffset.UtcNow,
|
||||
payload ?? new Dictionary<string, object?>(),
|
||||
stateBefore,
|
||||
stateAfter);
|
||||
var identity = TelemetryIdentityService.Instance;
|
||||
var distinctId = identity.TelemetryId;
|
||||
var seq = Interlocked.Increment(ref _sequence);
|
||||
|
||||
lock (_queueLock)
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
_eventQueue.Enqueue(eventData);
|
||||
["install_id"] = identity.InstallId,
|
||||
["telemetry_id"] = identity.TelemetryId,
|
||||
["session_id"] = _sessionId,
|
||||
["sequence"] = seq,
|
||||
["timestamp_utc"] = DateTimeOffset.UtcNow.ToString("o"),
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage()
|
||||
};
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
foreach (var kvp in payload)
|
||||
{
|
||||
properties[$"payload_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateBefore is not null && stateBefore.Count > 0)
|
||||
{
|
||||
foreach (var kvp in stateBefore)
|
||||
{
|
||||
properties[$"state_before_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateAfter is not null && stateAfter.Count > 0)
|
||||
{
|
||||
foreach (var kvp in stateAfter)
|
||||
{
|
||||
properties[$"state_after_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
_client.Capture(
|
||||
distinctId,
|
||||
eventName,
|
||||
properties,
|
||||
groups: null,
|
||||
sendFeatureFlags: false);
|
||||
|
||||
if (forceFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldFlush = false;
|
||||
lock (_queueLock)
|
||||
{
|
||||
shouldFlush = _eventQueue.Count >= 20;
|
||||
}
|
||||
|
||||
if (shouldFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushEvents()
|
||||
{
|
||||
List<TelemetryEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<TelemetryEvent>();
|
||||
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||
{
|
||||
eventsToSend.Add(_eventQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var telemetryEvent in eventsToSend)
|
||||
{
|
||||
if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var evt in eventsToSend)
|
||||
{
|
||||
if (_eventQueue.Count >= 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_eventQueue.Enqueue(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = telemetryEvent.EventName,
|
||||
["distinct_id"] = telemetryEvent.DistinctId,
|
||||
["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
|
||||
["properties"] = telemetryEvent.ToPostHogProperties()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flushImmediately)
|
||||
{
|
||||
AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearQueuedEvents()
|
||||
{
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Clear();
|
||||
_ = _client.FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,10 +1255,18 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
|
||||
internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
{
|
||||
private const string Codename = "Administrate";
|
||||
private const string DefaultCodename = "Administrate";
|
||||
|
||||
public string GetAppVersionText()
|
||||
{
|
||||
// 优先从环境变量读取(Launcher 传递)
|
||||
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envVersion))
|
||||
{
|
||||
return envVersion;
|
||||
}
|
||||
|
||||
// 回退:从程序集读取
|
||||
var assembly = typeof(App).Assembly;
|
||||
var informationalVersion = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
@@ -1268,7 +1306,15 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppCodenameText()
|
||||
{
|
||||
return Codename;
|
||||
// 优先从环境变量读取(Launcher 传递)
|
||||
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envCodename))
|
||||
{
|
||||
return envCodename;
|
||||
}
|
||||
|
||||
// 回退:使用默认开发代号
|
||||
return DefaultCodename;
|
||||
}
|
||||
|
||||
public AppRenderBackendInfo GetRenderBackendInfo()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record TelemetryEvent(
|
||||
string EventName,
|
||||
string DistinctId,
|
||||
string InstallId,
|
||||
string TelemetryId,
|
||||
string SessionId,
|
||||
long Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
IReadOnlyDictionary<string, object?> Payload,
|
||||
IReadOnlyDictionary<string, object?>? StateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? StateAfter = null)
|
||||
{
|
||||
public Dictionary<string, object?> ToPostHogProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["install_id"] = InstallId,
|
||||
["telemetry_id"] = TelemetryId,
|
||||
["session_id"] = SessionId,
|
||||
["sequence"] = Sequence,
|
||||
["timestamp_utc"] = Timestamp.ToString("o"),
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||
["payload"] = Copy(Payload)
|
||||
};
|
||||
|
||||
if (StateBefore is not null && StateBefore.Count > 0)
|
||||
{
|
||||
properties["state_before"] = Copy(StateBefore);
|
||||
}
|
||||
|
||||
if (StateAfter is not null && StateAfter.Count > 0)
|
||||
{
|
||||
properties["state_after"] = Copy(StateAfter);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
|
||||
{
|
||||
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
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;
|
||||
@@ -47,6 +50,13 @@ public sealed class UpdateWorkflowService
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly string _updatesDirectory;
|
||||
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignedFileMapSignatureName = "files.json.sig";
|
||||
private const string UpdateArchiveName = "update.zip";
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
@@ -56,6 +66,218 @@ public sealed class UpdateWorkflowService
|
||||
"Updates");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
|
||||
/// </summary>
|
||||
public static string GetLauncherIncomingDirectory()
|
||||
{
|
||||
// The app runs from app-{version}/ subdirectory; Launcher root is one level up.
|
||||
var appBaseDir = AppContext.BaseDirectory;
|
||||
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(launcherRoot))
|
||||
{
|
||||
launcherRoot = appBaseDir;
|
||||
}
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
|
||||
/// </summary>
|
||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release is null || release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
||||
/// </summary>
|
||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(incomingDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var downloadSource = state.UpdateDownloadSource;
|
||||
var downloadThreads = state.UpdateDownloadThreads;
|
||||
|
||||
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||
{
|
||||
(manifestAsset, SignedFileMapName),
|
||||
(signatureAsset, SignedFileMapSignatureName),
|
||||
(archiveAsset, UpdateArchiveName)
|
||||
};
|
||||
|
||||
var totalAssets = requiredAssets.Count;
|
||||
var completedAssets = 0;
|
||||
|
||||
foreach (var (asset, destinationFileName) in requiredAssets)
|
||||
{
|
||||
var destinationPath = Path.Combine(incomingDir, destinationFileName);
|
||||
|
||||
// Skip if already downloaded and file exists
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var assetProgress = progress is null ? null : new Progress<double>(p =>
|
||||
{
|
||||
var overallProgress = ((double)completedAssets + p) / totalAssets;
|
||||
progress.Report(overallProgress);
|
||||
});
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationPath,
|
||||
downloadSource,
|
||||
downloadThreads,
|
||||
assetProgress,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
// Clean up partially downloaded files
|
||||
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 update asset {asset.Name}: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
}
|
||||
|
||||
// Save state indicating a signed file-map update is pending.
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
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, SignedFileMapName), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the pending update is managed by Launcher incoming payload.
|
||||
/// </summary>
|
||||
public bool IsPendingDeltaUpdate()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var pendingPath = state.PendingUpdateInstallerPath?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(pendingPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -261,7 +483,7 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -272,7 +494,16 @@ public sealed class UpdateWorkflowService
|
||||
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
||||
// Prefer delta update if available (smaller download, faster)
|
||||
if (IsDeltaUpdateAvailable(result.Release))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
||||
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
||||
}
|
||||
else if (result.PreferredAsset is not null)
|
||||
{
|
||||
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
// For "Manual" mode, just check but don't download
|
||||
}
|
||||
@@ -302,6 +533,19 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
// For delta updates, launch the Launcher with apply-update command so it can
|
||||
// apply the update immediately with a progress UI, matching the full installer experience.
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI.");
|
||||
var launchResult = LaunchLauncherForApplyUpdate();
|
||||
if (launchResult)
|
||||
{
|
||||
ClearPendingUpdate();
|
||||
}
|
||||
return launchResult;
|
||||
}
|
||||
|
||||
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
|
||||
if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
|
||||
{
|
||||
@@ -311,6 +555,53 @@ public sealed class UpdateWorkflowService
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launches the Launcher process with the apply-update command to apply a pending delta update
|
||||
/// with a progress UI, providing an experience similar to a full installer.
|
||||
/// </summary>
|
||||
public bool LaunchLauncherForApplyUpdate()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherExeName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher";
|
||||
|
||||
// The Launcher is in the parent directory of the app's base directory
|
||||
// (app runs from app-{version}/ subdirectory, Launcher is at root)
|
||||
var appBaseDir = AppContext.BaseDirectory;
|
||||
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(launcherRoot))
|
||||
{
|
||||
launcherRoot = appBaseDir;
|
||||
}
|
||||
|
||||
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
|
||||
if (!File.Exists(launcherPath))
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{launcherRoot}\"",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = launcherRoot
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearPendingUpdate()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user