Compare commits

..

10 Commits

Author SHA1 Message Date
lincube
e9ff590d79 fix.可爱的我一直在修CI( 2026-04-16 14:45:44 +08:00
lincube
1aaf6cd0e9 试试 2026-04-16 14:17:46 +08:00
lincube
2f0c178df2 激进的更新 2026-04-16 01:59:21 +08:00
lincube
03e32ee6cb feat.网速显示组件引入了一套更好的等距。 2026-04-15 15:42:11 +08:00
lincube
c2cc62b58b feat.淡入淡出动画。 2026-04-15 10:49:04 +08:00
lincube
9c529f2992 feat.SDK更新 2026-04-14 16:47:32 +08:00
lincube
1e9ead8bee feat.SDK加入了FA的引用。 2026-04-14 12:25:28 +08:00
lincube
5f7b3a1e7d removed.移除了不附带.NET 10的轻量版安装包。 2026-04-14 00:52:16 +08:00
lincube
b12dd68ba7 fix.开发者调试工具设置无法正常持久化的问题。修复了插件无法进行更新的问题。 2026-04-14 00:22:02 +08:00
lincube
1b22e9df4a feat.新增了插件开发文档 2026-04-13 19:54:37 +08:00
110 changed files with 33133 additions and 837 deletions

View File

@@ -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:
@@ -63,7 +64,8 @@ jobs:
sudo apt-get install -y \
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0
- name: Setup .NET
uses: actions/setup-dotnet@v4

View File

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

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -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,19 +68,14 @@ jobs:
fail-fast: false
matrix:
include:
# 完整版(自包含 .NET 运行时)
- arch: x64
self_contained: true
suffix: ''
- arch: x86
self_contained: true
suffix: ''
# 轻盈版(框架依赖,仅 x64
- arch: x64
self_contained: false
suffix: '-lite'
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -104,7 +100,40 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$launcherPublishDir = "publish/launcher-win-$arch"
if ($selfContained) {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained `
-r win-$arch `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
} else {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
}
Write-Host "Launcher published to: $launcherPublishDir"
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" }
@@ -144,6 +173,43 @@ jobs:
Write-Host "Self-contained: $selfContained"
shell: pwsh
- 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
run: choco install innosetup -y --no-progress
shell: pwsh
@@ -157,24 +223,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) {
@@ -206,12 +268,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
@@ -225,27 +286,226 @@ 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: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-${{ matrix.arch }}"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its update.zip for diff ---
$previousVersion = $null
$previousAppPath = $null
try {
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
if ($previousRelease) {
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# Try to download update.zip from previous release for diff
$prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1
if ($prevUpdateZip) {
Write-Host "Found update.zip in previous release - extracting for diff..."
$prevZipDest = Join-Path $outputDir "prev-update.zip"
Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevZipDest -Force
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
} else {
Write-Host "No update.zip found in previous release - will generate full package"
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate file manifest with diff against previous version ---
Write-Host "Generating update package for version $version..."
$files = Get-ChildItem -Path $currentAppPath -Recurse -File
$fileEntries = [System.Collections.ArrayList]::new()
$changedFiles = [System.Collections.ArrayList]::new()
$reusedCount = 0
$addedCount = 0
$replacedCount = 0
$deletedCount = 0
# Build hash map of previous version files for quick lookup
$prevHashMap = @{}
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFiles = Get-ChildItem -Path $previousAppPath -Recurse -File
foreach ($pf in $prevFiles) {
$relPath = $pf.FullName.Substring($previousAppPath.Length).TrimStart('\', '/').Replace('\', '/')
if ($relPath -match '^\.(current|partial|destroy)$') { continue }
$prevHashMap[$relPath] = (Get-FileHash -Path $pf.FullName -Algorithm SHA256).Hash.ToLower()
}
Write-Host "Previous version has $($prevHashMap.Count) files for comparison"
}
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
# Skip deployment marker files
if ($relativePath -match '^\.(current|partial|destroy)$') {
continue
}
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower()
if ($prevHashMap.ContainsKey($relativePath)) {
$prevHash = $prevHashMap[$relativePath]
if ($hash -eq $prevHash) {
$fileEntries += @{ Path = $relativePath; Action = "reuse"; Sha256 = $hash }
$reusedCount++
} else {
$fileEntries += @{ Path = $relativePath; Action = "replace"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$replacedCount++
}
$prevHashMap.Remove($relativePath)
} else {
$fileEntries += @{ Path = $relativePath; Action = "add"; Sha256 = $hash; ArchivePath = $relativePath }
$changedFiles += $file
$addedCount++
}
}
# Files in previous version but not in current = deleted
foreach ($deletedPath in $prevHashMap.Keys) {
$fileEntries += @{ Path = $deletedPath; Action = "delete" }
$deletedCount++
}
Write-Host "Delta summary: $reusedCount reused, $replacedCount replaced, $addedCount added, $deletedCount deleted"
Write-Host "Changed files to include in update.zip: $($changedFiles.Count)"
$filesJson = @{
FromVersion = $previousVersion
ToVersion = $version
Platform = "windows"
Arch = "x64"
Files = $fileEntries
} | ConvertTo-Json -Depth 10
$filesJsonPath = Join-Path $outputDir "files.json"
$filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8
Write-Host "Generated files.json with $($fileEntries.Count) entries"
# Create update.zip with only changed files
$tempDir = Join-Path $outputDir "temp_staging"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
foreach ($file in $changedFiles) {
$relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/')
$destPath = Join-Path $tempDir $relativePath
$destDir = Split-Path -Parent $destPath
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
Copy-Item -Path $file.FullName -Destination $destPath -Force
}
$updateZipPath = Join-Path $outputDir "update.zip"
if ($changedFiles.Count -gt 0) {
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
} else {
# No changed files - create a minimal zip
$emptyMarker = Join-Path $tempDir ".no-changes"
Set-Content -Path $emptyMarker -Value ""
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
}
Remove-Item -Path $tempDir -Recurse -Force
Write-Host "Created update.zip: $([Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)) MB"
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
shell: pwsh
- name: Sign File Map
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "delta-output"
$filesJsonPath = Join-Path $outputDir "files.json"
$signaturePath = Join-Path $outputDir "files.json.sig"
if (-not (Test-Path $filesJsonPath)) {
Write-Error "files.json not found at $filesJsonPath"
exit 1
}
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
exit 0
}
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
}
}
"@
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
Remove-Item -Path $privateKeyPath -Force
Write-Host "Signed files.json -> files.json.sig"
shell: pwsh
- name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
uses: actions/upload-artifact@v4
with:
name: release-delta-windows-x64
path: |
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
@@ -258,7 +518,7 @@ jobs:
needs: prepare
runs-on: ubuntu-latest
name: Build_Linux
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -292,11 +552,24 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false
- 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 \
@@ -310,6 +583,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 }}"
@@ -319,28 +620,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
@@ -353,7 +650,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"
@@ -370,8 +667,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"
@@ -380,15 +676,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"
@@ -412,7 +706,7 @@ jobs:
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -437,11 +731,24 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false
- 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 \
@@ -455,45 +762,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>'
@@ -507,11 +826,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"
@@ -519,8 +837,7 @@ jobs:
echo "Error: Failed to create DMG"
exit 1
fi
# Cleanup
rm -rf dmg-temp "${app_name}.app"
- name: Upload
@@ -536,7 +853,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -546,29 +863,32 @@ 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 delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
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
@@ -586,20 +906,24 @@ jobs:
## Release ${{ needs.prepare.outputs.version }}
### Windows
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (完整版,包含 .NET 运行时)
- **LanMountainDesktop-Setup-{version}-x64-lite.exe** - 64-bit installer (轻量版,需安装 .NET 10 Runtime)
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (完整版,包含 .NET 运行时)
> **轻量版说明**:轻量版不包含 .NET 运行时,体积更小。首次运行前需安装 [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)。
- **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)
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
Existing users: The app will automatically detect and apply the incremental update on next launch.
### 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 }}

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

View 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

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

View File

@@ -0,0 +1,24 @@
* [x] AppSettingsSnapshot 包含 EnableSlideTransition 字段且默认为 false
* [x] DesktopPage 拥有名为 DesktopPageSlideTransform 的 TranslateTransform
* [x] DesktopPage.Transitions 包含 Opacity 和 TranslateTransform.X 两个 DoubleTransition
* [x] 点击"回到 Windows"时播放退场动画Opacity 淡出 或 Opacity+滑动),动画完成后再最小化
* [x] 从最小化恢复时 DesktopPage 先以 Opacity=0 遮住 Normal 中间态FullScreen 生效后播放入场动画
* [x] 动画期间 DesktopPage.IsHitTestVisible 为 false动画完成后恢复
* [x] 动画期间 OnWindowPropertyChanged 不执行强制全屏纠正
* [x] 快速连续操作不会导致动画冲突
* [x] GeneralSettingsPage 在 Windows 平台显示"滑入滑出过渡效果"开关
* [x] GeneralSettingsPage 在非 Windows 平台不显示该开关
* [x] EnableSlideTransition 设置持久化到 AppSettingsSnapshot 且立即生效
* [x] dotnet build 无编译错误

View File

@@ -0,0 +1,138 @@
# 窗口过渡动画 Spec
## Why
当前全屏窗口在"回到 Windows"(最小化)和"恢复应用"时存在严重的视觉问题:
1. 恢复时经历 `Minimized → Normal → FullScreen` 两步跳变,用户会短暂看到无框小窗口
2. 状态切换无任何过渡动画,体验生硬
3. `OnWindowPropertyChanged` 使用 `Dispatcher.UIThread.Post` 延迟纠正,进一步延长了 Normal 中间态的可见时间
## What Changes
-`MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform``TranslateTransform.X` 过渡动画
- 修改 `MainWindow.axaml.cs``OnMinimizeClick`,实现退场动画(滑出/淡出 → 最小化)
- 修改 `App.axaml.cs``RestoreOrCreateMainWindow`,实现入场动画(全屏 → 滑入/淡入)
- 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`,在动画期间暂停强制全屏逻辑
-`AppSettingsSnapshot` 中添加 `EnableSlideTransition` 设置项(默认关闭)
-`GeneralSettingsPageViewModel` 中添加对应 ViewModel 属性
-`GeneralSettingsPage.axaml` 中添加开关 UI仅 Windows 平台显示)
- 添加平台检测逻辑Windows 且开启设置时使用滑入滑出,其他情况使用 Opacity 淡入淡出
## Impact
- Affected specs: 窗口生命周期过渡动画
- Affected code:
- `LanMountainDesktop/Views/MainWindow.axaml` - DesktopPage 添加 TranslateTransform
- `LanMountainDesktop/Views/MainWindow.axaml.cs` - OnMinimizeClick、OnWindowPropertyChanged、新增动画方法
- `LanMountainDesktop/App.axaml.cs` - RestoreOrCreateMainWindow、OnMainWindowPropertyChanged
- `LanMountainDesktop/Models/AppSettingsSnapshot.cs` - 新增 EnableSlideTransition 字段
- `LanMountainDesktop/ViewModels/SettingsViewModels.cs` - GeneralSettingsPageViewModel 新增属性
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml` - 新增开关 UI
---
## ADDED Requirements
### Requirement: 窗口退场过渡动画
系统 SHALL 在主窗口最小化/隐藏时播放退场过渡动画,消除窗口状态跳变的视觉闪烁。
#### Scenario: Opacity 淡出退场(所有平台默认)
- **WHEN** 用户点击"回到 Windows"或触发最小化
- **THEN** 系统将 `DesktopPage.Opacity` 设为 0触发淡出动画
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPage.Opacity = 1`(窗口已不可见)
#### Scenario: 滑出退场Windows + 开启设置)
- **WHEN** 用户点击"回到 Windows"且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统同时将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPageSlideTransform.X = 0``DesktopPage.Opacity = 1`
### Requirement: 窗口入场过渡动画
系统 SHALL 在主窗口恢复时播放入场过渡动画,消除 Normal 中间态的视觉闪烁。
#### Scenario: Opacity 淡入入场(所有平台默认)
- **WHEN** 主窗口从最小化/隐藏状态恢复
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0遮住 Normal 中间态)
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后将 `DesktopPage.Opacity` 设为 1触发淡入动画
#### Scenario: 滑入入场Windows + 开启设置)
- **WHEN** 主窗口从最小化/隐藏状态恢复且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后同时将 `DesktopPage.Opacity` 设为 1 且 `DesktopPageSlideTransform.X` 设为 0触发滑入+淡入组合动画
### Requirement: 动画期间交互保护
系统 SHALL 在过渡动画播放期间防止用户交互和状态冲突。
#### Scenario: 动画期间禁止交互
- **WHEN** 退场或入场动画正在播放
- **THEN** `DesktopPage.IsHitTestVisible` 设为 `false`
- **AND THEN** 动画完成后恢复为 `true`
#### Scenario: 动画期间暂停强制全屏
- **WHEN** 入场动画正在播放且窗口临时处于 Normal 状态
- **THEN** `OnWindowPropertyChanged` 不执行强制全屏纠正
- **AND THEN** 入场动画完成后恢复正常强制全屏逻辑
#### Scenario: 防止快速连续操作
- **WHEN** 用户在动画播放期间再次触发最小化或恢复
- **THEN** 系统忽略重复操作,避免动画冲突
### Requirement: 滑入滑出设置项
系统 SHALL 在基本设置页面提供"滑入滑出过渡效果"开关,仅 Windows 平台可见。
#### Scenario: 设置项可见性
- **WHEN** 用户在 Windows 平台打开基本设置页面
- **THEN** 显示"滑入滑出过渡效果"开关
- **WHEN** 用户在非 Windows 平台打开基本设置页面
- **THEN** 不显示该开关
#### Scenario: 设置项默认值
- **WHEN** 用户首次安装应用
- **THEN** `EnableSlideTransition` 默认为 `false`
#### Scenario: 设置持久化
- **WHEN** 用户切换"滑入滑出过渡效果"开关
- **THEN** 设置值立即持久化到 `AppSettingsSnapshot.EnableSlideTransition`
- **AND THEN** 下次窗口过渡时立即生效,无需重启
### Requirement: DesktopPage TranslateTransform 声明
系统 SHALL 在 `DesktopPage` 上声明 `TranslateTransform` 和对应的过渡动画。
#### Scenario: XAML 声明
- **WHEN** MainWindow 初始化
- **THEN** `DesktopPage` 拥有名为 `DesktopPageSlideTransform``TranslateTransform`
- **AND THEN** `DesktopPage.Transitions` 包含 `Opacity``TranslateTransform.X` 两个过渡
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier
## MODIFIED Requirements
### Requirement: OnMinimizeClick 行为
**当前**: 直接设置 `WindowState = WindowState.Minimized`,无动画
**修改后**: 先播放退场动画,动画完成后再设置 `WindowState = WindowState.Minimized`
### Requirement: RestoreOrCreateMainWindow 行为
**当前**: `Show() → Normal → FullScreen`,无过渡动画,用户可见 Normal 中间态
**修改后**: 先将 `DesktopPage` 设为不可见Opacity=0 + 可选滑出位),再执行状态切换,最后播放入场动画
### Requirement: OnWindowPropertyChanged 强制全屏逻辑
**当前**: 任何非 Minimized/FullScreen 状态立即纠正为 FullScreen
**修改后**: 动画期间允许临时 Normal 状态存在,动画完成后恢复强制全屏逻辑
## REMOVED Requirements
无移除的需求。

View File

@@ -0,0 +1,52 @@
# Tasks
- [x] Task 1: 在 `AppSettingsSnapshot` 中添加 `EnableSlideTransition` 字段
- [x] 添加 `public bool EnableSlideTransition { get; set; } = false;`
- [x]`Clone()` 方法中无需特殊处理bool 是值类型)
- [x] Task 2: 在 `MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform` 和过渡动画
- [x] 添加 `<TranslateTransform />`
- [x]`Grid.Transitions` 中添加 `TranslateTransform.X``DoubleTransition`,使用 `FluttermotionToken.Duration.Intro` 和 DecelerateBezier 缓动
- [x] Task 3: 在 `MainWindow.axaml.cs` 中实现退场动画逻辑
- [x] 添加 `_isSlideAnimationActive` 标志位
- [x] 修改 `OnMinimizeClick`,调用新的 `SlideOutAndMinimizeAsync` 方法
- [x] 实现 `SlideOutAndMinimizeAsync`:读取设置 → 播放退场动画Opacity + 可选滑动)→ 等动画完成 → 最小化 → 重置位置
- [x] 动画期间设置 `DesktopPage.IsHitTestVisible = false`
- [x] Task 4: 在 `MainWindow.axaml.cs` 中实现入场动画逻辑
- [x] 添加 `public void PrepareEnterAnimation()` 方法:禁用过渡 → 设置初始位置Opacity=0, X=屏幕宽度或0→ 重新启用过渡
- [x] 添加 `public void PlayEnterAnimation()` 方法触发入场动画Opacity=1, X=0
- [x] 添加 `private bool IsSlideTransitionEnabled()` 方法,从设置中读取
- [x] Task 5: 修改 `App.axaml.cs``RestoreOrCreateMainWindow`
- [x] 在窗口状态切换前调用 `mainWindow.PrepareEnterAnimation()`
- [x] 在 FullScreen 状态生效后调用 `mainWindow.PlayEnterAnimation()`
- [x] Task 6: 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`
- [x]`_isSlideAnimationActive` 为 true 时跳过强制全屏逻辑
- [x] Task 7: 在 `GeneralSettingsPageViewModel` 中添加 `EnableSlideTransition` 属性
- [x] 添加 `[ObservableProperty] private bool _enableSlideTransition;`
- [x] 添加 `OnEnableSlideTransitionChanged` 持久化方法
- [x] 在构造函数和 `OnSettingsChanged` 中加载/同步该设置
- [x] 添加 `IsSlideTransitionAvailable` 平台检测属性
- [x] Task 8: 在 `GeneralSettingsPage.axaml` 中添加"滑入滑出过渡效果"开关
- [x] 在"运行时设置"分组中添加 `SettingsExpander`
- [x] 仅 Windows 平台显示(使用 `IsVisible` 绑定到 `IsSlideTransitionAvailable`
- [x] 图标使用 `ArrowRight`
- [x] Task 9: 构建验证
- [x] 执行 `dotnet build` 确保无编译错误
# Task Dependencies
- [Task 2] depends on [Task 1]
- [Task 3] depends on [Task 1, Task 2]
- [Task 4] depends on [Task 1, Task 2]
- [Task 5] depends on [Task 4]
- [Task 6] depends on [Task 3]
- [Task 7] depends on [Task 1]
- [Task 8] depends on [Task 7]
- [Task 9] depends on [Task 3, Task 4, Task 5, Task 6, Task 7, Task 8]

View File

@@ -1,13 +1,35 @@
# 更新日志 / Changelog
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
## [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)
-
### 变更 (Changed)
@@ -15,11 +37,46 @@
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
- 提供更灵活的设置页面展示方式,提升插件用户体验
- 兼容原有的设置方式,平滑过渡
- 🔧 **三指滑动与融合桌面功能开关位置调整**: 将三指滑动与融合桌面功能开关移动到了开发者设置界面
- 优化设置页面结构,将高级功能集中管理
- 普通用户界面更加简洁,开发者可在已有的开发者设置界面中访问相关设置
### 修复 (Fixed)
- 🐛 **快捷方式组件透明问题**: 修复了快捷方式组件无法正常透明的问题
- 问题原因: 组件背景透明属性设置异常或渲染层级问题
- 修复方案: 修正透明属性配置,确保快捷方式组件背景透明效果正常显示
- 🐛 **插件无法正常升级问题**: 修复了插件无法正常升级的问题
- 问题原因: 插件升级流程中存在异常,导致升级操作失败或中断
- 修复方案: 修复插件升级逻辑,确保插件可以正常检测、下载和安装更新
- 🐛 **开发者设置项持久化问题**: 修复了开发者设置项不能正确持久化的问题
- 问题原因: 开发者设置项的保存或读取逻辑存在缺陷,导致设置无法正确保存或恢复
- 修复方案: 修复设置持久化逻辑,确保开发者设置项能够正确保存并在重启后恢复
### 移除 (Removed)
- 🗑️ **不附带 .NET 10 依赖的轻量版安装包**: 移除了不附带 .NET 10 依赖的轻量版安装包
- 简化版本发布和维护流程,统一提供完整依赖的安装包
- 用户无需担心 .NET 运行时环境,安装后即可直接使用
***
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
- ♻️ **插件 SDK 更新**: 更新插件 SDK优化插件开发接口和兼容性
### 修复 (Fixed)
- 🐛 **轻量版 .NET 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 环境下的依赖问题
- 问题原因: 轻量版与 .NET 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-

View File

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

View File

@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Launcher.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,50 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
_ = RunCoordinatorAsync(desktop, coordinator);
}
base.OnFrameworkInitializationCompleted();
}
private static async Task RunCoordinatorAsync(
IClassicDesktopStyleApplicationLifetime desktop,
LauncherFlowCoordinator coordinator)
{
var result = await coordinator.RunAsync().ConfigureAwait(false);
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);
}
}

View File

@@ -0,0 +1,8 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
-----END RSA PUBLIC KEY-----

View File

@@ -0,0 +1,79 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public string Command { get; }
public string SubCommand { get; }
public IReadOnlyDictionary<string, string> Options { get; }
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
{
Command = command;
SubCommand = subCommand;
Options = options;
}
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);
}
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;
}
}

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<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>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- Embed public-key.pem and copy to .launcher/update/ in output directory -->
<ItemGroup>
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
</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>

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher;
internal static class LauncherRuntimeContext
{
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
}

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

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

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

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

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

View File

@@ -0,0 +1 @@
MessageBox

View File

@@ -0,0 +1,191 @@
using System.Diagnostics;
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
#if WINDOWS
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
#endif
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);
}
// 处理其他 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 int LaunchMainApplication(string[] args)
{
// 获取可执行文件名
string executableName = OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
// 获取安装根目录
var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? "");
// 查找最佳版本
var installation = FindBestVersion(rootDir, executableName);
if (installation == null)
{
ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。");
return 1;
}
var exePath = Path.Combine(installation, executableName);
// Linux/macOS: 自动添加可执行权限
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
try
{
var chmod = Process.Start(new ProcessStartInfo
{
FileName = "chmod",
Arguments = $"+x \"{exePath}\"",
CreateNoWindow = true
});
chmod?.WaitForExit();
}
catch (Exception ex)
{
Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}");
}
}
// 清理待删除的旧版本
CleanupDestroyedVersions(rootDir);
// 启动主程序
var startInfo = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = rootDir,
UseShellExecute = false
};
foreach (var arg in args)
{
startInfo.ArgumentList.Add(arg);
}
// 传递包根目录环境变量
startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir;
try
{
Process.Start(startInfo);
return 0;
}
catch (Exception ex)
{
ShowError($"启动主程序失败: {ex.Message}");
return 1;
}
}
private static string? FindBestVersion(string rootDir, string executableName)
{
return Directory.GetDirectories(rootDir)
.Where(x =>
{
var dirName = Path.GetFileName(x);
return dirName.StartsWith("app-") &&
!File.Exists(Path.Combine(x, ".destroy")) &&
!File.Exists(Path.Combine(x, ".partial")) &&
File.Exists(Path.Combine(x, executableName));
})
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先
.ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序
.FirstOrDefault();
}
private static Version ParseVersion(string dirName)
{
// 从 "app-1.0.0" 格式解析版本号
var parts = dirName.Split('-');
if (parts.Length >= 2 && Version.TryParse(parts[1], out var version))
{
return version;
}
return new Version(0, 0, 0);
}
private static void CleanupDestroyedVersions(string rootDir)
{
try
{
var destroyedDirs = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch
{
// 忽略清理失败
}
}
private static void ShowError(string message)
{
#if WINDOWS
try
{
PInvoke.MessageBox(
HWND.Null,
message,
"LanMountainDesktop Launcher",
MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK
);
}
catch
{
Console.Error.WriteLine(message);
}
#else
Console.Error.WriteLine(message);
#endif
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View 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"
}
}
}
}

View File

@@ -0,0 +1,174 @@
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 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),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
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, new JsonSerializerOptions
{
WriteIndented = true
});
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;
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
return File.Exists(parentHost) ? parent : baseDir;
}
}

View File

@@ -0,0 +1,32 @@
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));
}
}
}

View File

@@ -0,0 +1,160 @@
using System.Globalization;
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()
{
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
// 过滤掉无效的部署目录
var validCandidates = candidates
.Where(path =>
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
.ToList();
// 优先选择带 .current 标记的版本
var withMarkers = validCandidates
.Where(path => File.Exists(Path.Combine(path, ".current")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
if (withMarkers.Count > 0)
{
return withMarkers[0].Path;
}
// 如果没有 .current 标记,选择最新版本
var byVersion = validCandidates
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
return byVersion.Count > 0 ? byVersion[0].Path : null;
}
public string? ResolveHostExecutablePath()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
return File.Exists(inParent) ? inParent : null;
}
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++;
}
}
public void CleanupDestroyedDeployments()
{
try
{
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
var destroyedDirs = candidates
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch
{
// 忽略清理失败
}
}
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];
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface IOobeStep
{
Task RunAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
}

View File

@@ -0,0 +1,194 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
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()
{
try
{
// 清理待删除的旧版本
_deploymentLocator.CleanupDestroyedDeployments();
if (_oobeStateService.IsFirstRun())
{
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
}
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
var reporter = (ISplashStageReporter)splashWindow;
try
{
reporter.Report("silentUpdate", "update");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
return updateResult;
}
reporter.Report("pluginTasks", "plugins");
var pluginsDir = _context.GetOption("plugins-dir")
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
{
return queueResult;
}
reporter.Report("launchHost", "launch");
var hostResult = LaunchHost();
if (!hostResult.Success)
{
return hostResult;
}
return new LauncherResult
{
Success = true,
Stage = "exit",
Code = "ok",
Message = "Launcher completed successfully."
};
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
}
}
catch (Exception ex)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
}
private LauncherResult LaunchHost()
{
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
if (string.IsNullOrWhiteSpace(hostPath))
{
return new LauncherResult
{
Success = false,
Stage = "launchHost",
Code = "host_not_found",
Message = "LanMountainDesktop host executable not found."
};
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
EnsureExecutable(hostPath);
}
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
};
Process.Start(processStartInfo);
return new LauncherResult
{
Success = true,
Stage = "launchHost",
Code = "ok",
Message = "Host launched."
};
}
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)
{
var window = await Dispatcher.UIThread.InvokeAsync(() =>
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
return oobeWindow;
});
try
{
using var _ = cancellationToken.Register(() => window.Close());
await window.WaitForEnterAsync().ConfigureAwait(false);
_stateService.MarkCompleted();
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => window.Close());
}
}
}
}

View File

@@ -0,0 +1,29 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService
{
private readonly string _markerPath;
public OobeStateService(string appRoot)
{
var stateDir = Path.Combine(appRoot, ".launcher", "state");
Directory.CreateDirectory(stateDir);
_markerPath = Path.Combine(stateDir, "first_run_completed");
}
public bool IsFirstRun()
{
return !File.Exists(_markerPath);
}
public void MarkCompleted()
{
var dir = Path.GetDirectoryName(_markerPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
}
}

View File

@@ -1,10 +1,10 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Launcher.Models;
internal static class Program
namespace LanMountainDesktop.Launcher.Services;
internal sealed class PluginInstallerService
{
private static readonly TimeSpan[] RetryDelays =
[
@@ -13,103 +13,38 @@ internal static class Program
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args)
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
{
var result = new HelperResult();
string? resultPath = null;
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
try
if (!File.Exists(fullSourcePath))
{
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
};
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
if (!string.IsNullOrWhiteSpace(resultPath))
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
{
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;
Success = true,
Stage = "plugin.install",
Code = "ok",
Message = "Plugin installed.",
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
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)
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
@@ -132,9 +67,12 @@ internal static class Program
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
private 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)
@@ -154,11 +92,45 @@ internal static class Program
continue;
}
DeleteFileWithRetry(existingPackagePath);
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
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
}
@@ -187,7 +159,6 @@ internal static class Program
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
@@ -226,17 +197,4 @@ internal static class Program
? 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; }
}
}

View File

@@ -0,0 +1,97 @@
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<List<PendingUpgrade>>(text) ?? [];
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, new JsonSerializerOptions
{
WriteIndented = true
}));
}
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)}."
};
}
private 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);
}
}
}

View File

@@ -0,0 +1,168 @@
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;
private readonly JsonSerializerOptions _jsonOptions;
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");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
/// <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<List<GitHubRelease>>(json, _jsonOptions);
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 响应模型
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}
}

View File

@@ -0,0 +1,512 @@
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<SignedFileMap>(fileMapText);
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<SignedFileMap>(fileMapText);
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 Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
}
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();
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
{
}
}
}
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<SnapshotMetadata>(File.ReadAllText(snapshotPath));
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, new JsonSerializerOptions
{
WriteIndented = true
}));
}
private static LauncherResult Failed(string stage, string code, string message)
{
return new LauncherResult
{
Success = false,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = message
};
}
}

View File

@@ -0,0 +1,22 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
Title="阑山桌面"
Width="420"
Height="260"
CanResize="False"
WindowStartupLocation="CenterScreen">
<Grid Margin="24" RowDefinitions="*,Auto">
<TextBlock Text="欢迎使用阑山桌面"
FontSize="26"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
<Button Grid.Row="1"
x:Name="EnterButton"
HorizontalAlignment="Right"
Width="64"
Height="40"
Content="→"
FontSize="18" />
</Grid>
</Window>

View File

@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace LanMountainDesktop.Launcher.Views;
internal partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
var enterButton = this.FindControl<Button>("EnterButton");
if (enterButton is not null)
{
enterButton.Click += OnEnterClick;
}
}
public Task WaitForEnterAsync() => _completionSource.Task;
private void OnEnterClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(true);
}
}

View File

@@ -0,0 +1,40 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
Title="阑山桌面"
Width="420"
Height="240"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None">
<Grid Margin="24" RowDefinitions="*,Auto,Auto,Auto">
<TextBlock x:Name="AppNameText"
Text="阑山桌面"
FontSize="34"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Grid.Row="0" />
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
Margin="0,12,0,0"
IsIndeterminate="True" />
<TextBlock x:Name="StageText"
Grid.Row="2"
FontSize="12"
Foreground="#999999"
HorizontalAlignment="Center"
Margin="0,8,0,0"
Text="" />
<TextBlock x:Name="DetailText"
Grid.Row="3"
FontSize="11"
Foreground="#BBBBBB"
HorizontalAlignment="Center"
Margin="0,2,0,0"
Text="" />
</Grid>
</Window>

View File

@@ -0,0 +1,48 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
internal partial class SplashWindow : Window, ISplashStageReporter
{
private static readonly (string Stage, string Label, double Progress)[] StageMap =
[
("bootstrap", "正在初始化...", 10),
("silentUpdate", "正在应用更新...", 35),
("pluginTasks", "正在处理插件...", 65),
("launchHost", "正在启动...", 90),
];
public SplashWindow()
{
AvaloniaXamlLoader.Load(this);
}
public void Report(string stage, string message)
{
var (label, progress) = ResolveStageInfo(stage);
var stageText = this.GetControl<TextBlock>("StageText");
var detailText = this.GetControl<TextBlock>("DetailText");
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
stageText.Text = label;
detailText.Text = message;
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
private static (string Label, double Progress) ResolveStageInfo(string stage)
{
foreach (var (s, label, progress) in StageMap)
{
if (string.Equals(s, stage, StringComparison.OrdinalIgnoreCase))
{
return (label, progress);
}
}
return (stage, 0);
}
}

View File

@@ -0,0 +1,4 @@
using Avalonia.Metadata;
[assembly: XmlnsPrefix("http://lanmountain.tech/schemas/xaml/sdk", "lmd")]
[assembly: XmlnsDefinition("http://lanmountain.tech/schemas/xaml/sdk", "LanMountainDesktop.PluginSdk")]

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.0.1</Version>
<Version>4.0.2</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
@@ -20,6 +20,9 @@
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" ExcludeAssets="runtime" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />

View File

@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "4.0.1";
public const string ApiVersion = "4.0.2";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";

View File

@@ -47,7 +47,7 @@
"pluginSdkVersion": {
"type": "parameter",
"datatype": "text",
"defaultValue": "4.0.0",
"defaultValue": "4.0.2",
"description": "LanMountainDesktop.PluginSdk package version.",
"replaces": "__PLUGIN_SDK_VERSION__"
}

View File

@@ -4,7 +4,7 @@
"description": "__PLUGIN_DESCRIPTION__",
"author": "__PLUGIN_AUTHOR__",
"version": "1.0.0",
"apiVersion": "4.0.1",
"apiVersion": "4.0.2",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": []
}

View File

@@ -1,14 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AssemblyName>LanMountainDesktop.PluginUpgradeHelper</AssemblyName>
<RootNamespace>LanMountainDesktop.PluginUpgradeHelper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,372 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginUpgradeHelper;
internal static class Program
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private const string LogFileName = "plugin-upgrade-helper.log";
private static int Main(string[] args)
{
var logPath = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", LogFileName);
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
File.AppendAllText(logPath, $"\n[{DateTime.Now:O}] PluginUpgradeHelper started. Args: {string.Join(" ", args)}\n");
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
string.IsNullOrWhiteSpace(pluginsDirectory))
{
LogError(logPath, "Missing required argument: --plugins-dir");
return 1;
}
if (!parsedArgs.TryGetValue("parent-pid", out var parentPidStr) ||
!int.TryParse(parentPidStr, out var parentPid))
{
LogError(logPath, "Missing or invalid argument: --parent-pid");
return 1;
}
parsedArgs.TryGetValue("launch", out var launchCommand);
LogInfo(logPath, $"Waiting for parent process {parentPid} to exit...");
WaitForParentProcess(parentPid);
LogInfo(logPath, $"Processing pending upgrades in '{pluginsDirectory}'...");
var upgradeResults = ProcessPendingUpgrades(pluginsDirectory, logPath);
LogInfo(logPath, $"Upgrades completed. Success: {upgradeResults.SuccessCount}, Failed: {upgradeResults.FailureCount}");
if (!string.IsNullOrWhiteSpace(launchCommand))
{
LogInfo(logPath, $"Launching application: {launchCommand}");
LaunchApplication(launchCommand, parsedArgs);
}
return upgradeResults.FailureCount > 0 ? 2 : 0;
}
catch (Exception ex)
{
LogError(logPath, $"Unexpected error: {ex}");
return 1;
}
}
private static void WaitForParentProcess(int parentPid)
{
try
{
var parentProcess = Process.GetProcessById(parentPid);
parentProcess.WaitForExit(TimeSpan.FromSeconds(30));
}
catch (ArgumentException)
{
// Process already exited
}
catch (Exception)
{
// Ignore errors, continue anyway
}
Thread.Sleep(500);
}
private static UpgradeResults ProcessPendingUpgrades(string pluginsDirectory, string logPath)
{
var pendingUpgradesPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
var successCount = 0;
var failureCount = 0;
if (!File.Exists(pendingUpgradesPath))
{
LogInfo(logPath, "No pending upgrades found.");
return new UpgradeResults(0, 0);
}
List<PendingUpgrade>? pendingUpgrades;
try
{
var json = File.ReadAllText(pendingUpgradesPath);
pendingUpgrades = JsonSerializer.Deserialize<List<PendingUpgrade>>(json);
}
catch (Exception ex)
{
LogError(logPath, $"Failed to read pending upgrades: {ex.Message}");
return new UpgradeResults(0, 0);
}
if (pendingUpgrades is null || pendingUpgrades.Count == 0)
{
LogInfo(logPath, "No pending upgrades to process.");
return new UpgradeResults(0, 0);
}
Directory.CreateDirectory(pluginsDirectory);
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var upgrade in pendingUpgrades)
{
if (!upgrade.IsValid())
{
LogWarn(logPath, $"Skipping invalid upgrade entry for plugin '{upgrade.PluginId}'.");
failureCount++;
continue;
}
try
{
LogInfo(logPath, $"Processing upgrade for plugin '{upgrade.PluginId}' to version '{upgrade.TargetVersion}'...");
var manifest = ReadManifestFromPackage(upgrade.SourcePackagePath);
var destinationPath = Path.Combine(pluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
RemoveExistingPluginPackages(pluginsDirectory, manifest.Id, destinationPath, pendingDeletionDir, logPath);
File.Copy(upgrade.SourcePackagePath, destinationPath, overwrite: true);
LogInfo(logPath, $"Successfully upgraded plugin '{upgrade.PluginId}' to '{upgrade.TargetVersion}'.");
successCount++;
}
catch (Exception ex)
{
LogError(logPath, $"Failed to upgrade plugin '{upgrade.PluginId}': {ex.Message}");
failureCount++;
}
}
try
{
File.Delete(pendingUpgradesPath);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending upgrades file: {ex.Message}");
}
CleanupPendingDeletions(pendingDeletionDir, logPath);
return new UpgradeResults(successCount, failureCount);
}
private static void RemoveExistingPluginPackages(
string pluginsDirectory,
string pluginId,
string destinationPath,
string pendingDeletionDir,
string logPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), ".runtime"));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryDeleteOrMoveFile(existingPackagePath, pendingDeletionDir, logPath);
}
catch
{
// Ignore unrelated or malformed packages
}
}
}
private static void TryDeleteOrMoveFile(string filePath, string pendingDeletionDir, string logPath)
{
try
{
File.Delete(filePath);
LogInfo(logPath, $"Deleted existing package: {filePath}");
}
catch (IOException)
{
var fileName = Path.GetFileName(filePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
try
{
File.Move(filePath, pendingPath);
LogInfo(logPath, $"Moved existing package to pending deletion: {filePath} -> {pendingPath}");
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to move existing package '{filePath}': {ex.Message}");
}
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir, string logPath)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending file '{pendingFile}': {ex.Message}");
}
}
try
{
if (Directory.GetFiles(pendingDeletionDir).Length == 0 &&
Directory.GetDirectories(pendingDeletionDir).Length == 0)
{
Directory.Delete(pendingDeletionDir);
}
}
catch
{
// Ignore
}
}
private static void LaunchApplication(string launchCommand, Dictionary<string, string> args)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = launchCommand,
UseShellExecute = true,
WorkingDirectory = args.TryGetValue("working-dir", out var workingDir)
? workingDir
: AppContext.BaseDirectory
};
if (args.TryGetValue("launch-args", out var launchArgs) && !string.IsNullOrWhiteSpace(launchArgs))
{
startInfo.Arguments = launchArgs;
}
Process.Start(startInfo);
}
catch (Exception ex)
{
Debug.WriteLine($"[PluginUpgradeHelper] Failed to launch application: {ex}");
}
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + ".laapp";
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
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 void LogInfo(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [INFO] {message}\n");
}
private static void LogWarn(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [WARN] {message}\n");
}
private static void LogError(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [ERROR] {message}\n");
}
private 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);
}
}
private sealed record UpgradeResults(int SuccessCount, int FailureCount);
}

View File

@@ -7,7 +7,8 @@
<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" />
</Solution>

View File

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

View File

@@ -404,10 +404,7 @@ public partial class App : Application
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
if (_trayComponentLibraryMenuItem is not null)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
RefreshFusedDesktopMenuItemVisibility();
if (_trayRestartMenuItem is not null)
{
@@ -420,6 +417,30 @@ public partial class App : Application
}
}
private void RefreshFusedDesktopMenuItemVisibility()
{
if (_trayComponentLibraryMenuItem is null)
{
return;
}
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows())
{
_trayComponentLibraryMenuItem.IsVisible = false;
return;
}
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
if (_trayComponentLibraryMenuItem.IsVisible)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
}
private void DisposeTrayIcon()
{
if (_trayIcon is null)
@@ -545,13 +566,14 @@ public partial class App : Application
try
{
// 先隐藏透明覆盖层窗口
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
@@ -572,6 +594,12 @@ public partial class App : Application
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
@@ -687,6 +715,16 @@ public partial class App : Application
ApplyCurrentCultureFromSettings();
RefreshTrayIconContent();
}
// 检查融合桌面设置是否变更
var fusedDesktopChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase);
if (fusedDesktopChanged)
{
RefreshFusedDesktopMenuItemVisibility();
}
}, DispatcherPriority.Background);
}

View File

@@ -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" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
@@ -76,20 +76,37 @@
<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">
<Target Name="CopyLauncherToOutput" AfterTargets="Build">
<PropertyGroup>
<_LauncherOutputPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherOutputPath>
</PropertyGroup>
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherFiles Include="$(_LauncherOutputPath)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherOutputPath)')" />
</Target>
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<Target Name="PublishLauncher" BeforeTargets="CopyLauncherToPublish" Condition="'$(PublishDir)' != '' and '$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<_LauncherPublishPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishPath>
</PropertyGroup>
<MSBuild Projects="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
Targets="Publish"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=$(SelfContained);PublishDir=$(_LauncherPublishPath);PublishSingleFile=false;PublishTrimmed=false;PublishReadyToRun=false;DebugType=none;DebugSymbols=false" />
</Target>
<Target Name="CopyLauncherToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<PropertyGroup>
<_LauncherPublishSource Condition="'$(RuntimeIdentifier)' != '' and Exists('..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\')">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishSource>
<_LauncherPublishSource Condition="'$(_LauncherPublishSource)' == ''">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherPublishSource>
</PropertyGroup>
<ItemGroup>
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherPublishFiles Include="$(_LauncherPublishSource)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherPublishSource)')" />
</Target>
</Project>

View File

@@ -152,6 +152,10 @@ public sealed class AppSettingsSnapshot
public bool EnableThreeFingerSwipe { get; set; } = false;
public bool EnableSlideTransition { get; set; } = false;
public bool EnableFusedDesktop { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }

View 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"
}
}
}
}

View File

@@ -58,6 +58,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{
if (!OperatingSystem.IsWindows()) return;
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (!appSnapshot.EnableFusedDesktop)
{
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
return;
}
EnsureRegistries();
ReloadWidgets();
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.IO;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
@@ -9,6 +10,8 @@ namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
App? app = null;
@@ -50,28 +53,14 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
App? app = null;
try
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
app = Application.Current as App;
if (HasPendingPluginUpgrades())
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
return TryRestartWithUpgradeHelper(request);
}
Process.Start(startInfo);
app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
return TryRestartDirectly(request);
}
catch (Exception ex)
{
@@ -80,4 +69,92 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return false;
}
}
private static bool HasPendingPluginUpgrades()
{
try
{
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
return File.Exists(pendingUpgradesPath);
}
catch
{
return false;
}
}
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
{
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
var helperPath = ResolveUpgradeHelperPath();
if (!File.Exists(helperPath))
{
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
return TryRestartDirectly(request);
}
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var startInfo = AppRestartService.CreateRestartStartInfo();
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
var launchArgs = startInfo?.Arguments ?? "";
var helperStartInfo = new ProcessStartInfo
{
FileName = helperPath,
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
UseShellExecute = true,
WorkingDirectory = AppContext.BaseDirectory
};
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
Process.Start(startInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
}
private static string ResolveUpgradeHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
}
}

View File

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

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
namespace LanMountainDesktop.Services;
public sealed class PendingPluginUpgradeService
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private static readonly Lock Gate = new();
private readonly string _pendingUpgradesFilePath;
public PendingPluginUpgradeService(string pluginsDirectory)
{
_pendingUpgradesFilePath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
}
public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades()
{
lock (Gate)
{
return ReadPendingUpgradesCore();
}
}
public void AddPendingUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion);
lock (Gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
upgrades.Add(new PendingPluginUpgrade(
pluginId,
Path.GetFullPath(sourcePackagePath),
targetVersion,
DateTimeOffset.UtcNow));
SavePendingUpgradesCore(upgrades);
AppLogger.Info(
"PendingPluginUpgrade",
$"Added pending upgrade. PluginId='{pluginId}'; TargetVersion='{targetVersion}'; SourcePath='{sourcePackagePath}'.");
}
}
public void RemovePendingUpgrade(string pluginId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
lock (Gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
var removed = upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
if (removed > 0)
{
SavePendingUpgradesCore(upgrades);
AppLogger.Info(
"PendingPluginUpgrade",
$"Removed pending upgrade. PluginId='{pluginId}'.");
}
}
}
public void ClearPendingUpgrades()
{
lock (Gate)
{
if (File.Exists(_pendingUpgradesFilePath))
{
File.Delete(_pendingUpgradesFilePath);
AppLogger.Info("PendingPluginUpgrade", "Cleared all pending upgrades.");
}
}
}
public bool HasPendingUpgrades()
{
lock (Gate)
{
return ReadPendingUpgradesCore().Count > 0;
}
}
private List<PendingPluginUpgrade> ReadPendingUpgradesCore()
{
if (!File.Exists(_pendingUpgradesFilePath))
{
return [];
}
try
{
var json = File.ReadAllText(_pendingUpgradesFilePath);
var upgrades = JsonSerializer.Deserialize<List<PendingPluginUpgrade>>(json);
return upgrades?.Where(u => u.IsValid()).ToList() ?? [];
}
catch (Exception ex)
{
AppLogger.Warn(
"PendingPluginUpgrade",
$"Failed to read pending upgrades from '{_pendingUpgradesFilePath}'.",
ex);
return [];
}
}
private void SavePendingUpgradesCore(List<PendingPluginUpgrade> upgrades)
{
try
{
var directory = Path.GetDirectoryName(_pendingUpgradesFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(upgrades, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(_pendingUpgradesFilePath, json);
}
catch (Exception ex)
{
AppLogger.Error(
"PendingPluginUpgrade",
$"Failed to save pending upgrades to '{_pendingUpgradesFilePath}'.",
ex);
throw;
}
}
}
public sealed record PendingPluginUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk;
@@ -47,6 +49,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 DeltaManifestFileName = "files.json";
private const string DeltaSignatureFileName = "files.json.sig";
private const string DeltaArchiveFileName = "update.zip";
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
@@ -56,6 +65,175 @@ 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 delta update assets (files.json, files.json.sig, update.zip).
/// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{
if (release is null || release.Assets is null || release.Assets.Count == 0)
{
return false;
}
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
return assetNames.Contains(DeltaManifestFileName)
&& assetNames.Contains(DeltaSignatureFileName)
&& assetNames.Contains(DeltaArchiveFileName);
}
/// <summary>
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
/// </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 (!IsDeltaUpdateAvailable(checkResult.Release))
{
return new UpdateDownloadResult(false, null, "Release does not contain delta update 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 Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = null!,
[DeltaSignatureFileName] = null!,
[DeltaArchiveFileName] = null!
};
foreach (var asset in checkResult.Release.Assets)
{
if (requiredAssets.ContainsKey(asset.Name))
{
requiredAssets[asset.Name] = asset;
}
}
if (requiredAssets.Any(kvp => kvp.Value is null))
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
var totalAssets = requiredAssets.Count;
var completedAssets = 0;
foreach (var (name, asset) in requiredAssets)
{
var destinationPath = Path.Combine(incomingDir, name);
// 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", $"Delta 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.Keys)
{
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
}
return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}");
}
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
}
// Save state indicating a delta update is pending
SaveState(state with
{
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = null
});
AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null);
}
/// <summary>
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer.
/// </summary>
public bool IsPendingDeltaUpdate()
{
var state = _settingsFacade.Update.Get();
var pendingPath = state.PendingUpdateInstallerPath?.Trim();
if (string.IsNullOrWhiteSpace(pendingPath))
{
return false;
}
// Delta updates are identified by the manifest file path
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
public UpdatePendingInfo? GetPendingUpdate()
{
var state = _settingsFacade.Update.Get();
@@ -261,7 +439,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 +450,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 +489,15 @@ public sealed class UpdateWorkflowService
return false;
}
// For delta updates, the files are already in .launcher/update/incoming/.
// Just exit the app - the Launcher will detect and apply the update on next startup.
if (IsPendingDeltaUpdate())
{
AppLogger.Info("UpdateWorkflow", "Delta update pending in incoming directory. Exiting to let Launcher apply on next startup.");
ClearPendingUpdate();
return true;
}
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
{

View File

@@ -174,9 +174,6 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
private bool _isInitializing;
private bool _disposed;
[ObservableProperty]
private bool _enableThreeFingerSwipe;
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
@@ -204,7 +201,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
?? RenderModes[0];
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
EnableSlideTransition = appSnapshot.EnableSlideTransition;
_isInitializing = false;
RefreshPreview();
@@ -236,33 +233,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
{
return;
}
// 如果是其他设置变更,重新加载我们的设置
_isInitializing = true;
try
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
}
finally
{
_isInitializing = false;
}
}
partial void OnEnableThreeFingerSwipeChanged(bool value)
{
if (_isInitializing)
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)))
{
return;
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.EnableThreeFingerSwipe = value;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]);
}
public event Action? RestartRequested;
@@ -282,6 +257,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
[ObservableProperty]
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
[ObservableProperty]
private bool _enableSlideTransition;
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
[ObservableProperty]
private string _pageTitle = string.Empty;
@@ -381,6 +361,24 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
}
}
partial void OnEnableSlideTransitionChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
}
private void SaveField<T>(string key, T value)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var property = typeof(AppSettingsSnapshot).GetProperty(key);
if (property is not null && property.CanWrite)
{
property.SetValue(snapshot, value);
}
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
}
private IReadOnlyList<SelectionOption> CreateLanguageOptions()
{
return
@@ -3100,6 +3098,9 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
_isInitializing = true;
LoadSettings();
_isInitializing = false;
// 监听设置变更,防止被意外重置
_settingsFacade.Settings.Changed += OnSettingsChanged;
}
[ObservableProperty]
@@ -3108,6 +3109,12 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _devPluginPath = string.Empty;
[ObservableProperty]
private bool _enableThreeFingerSwipe;
[ObservableProperty]
private bool _enableFusedDesktop;
partial void OnIsDevModeEnabledChanged(bool value)
{
if (_isInitializing) return;
@@ -3120,11 +3127,52 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
SaveField(nameof(AppSettingsSnapshot.DevPluginPath), value);
}
partial void OnEnableThreeFingerSwipeChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), value);
}
partial void OnEnableFusedDesktopChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableFusedDesktop), value);
}
private void LoadSettings()
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
IsDevModeEnabled = snapshot.IsDevModeEnabled;
DevPluginPath = snapshot.DevPluginPath ?? string.Empty;
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
if (e.Scope != SettingsScope.App)
{
return;
}
var changedKeys = e.ChangedKeys?.ToArray();
if (changedKeys is null || changedKeys.Length == 0)
{
return;
}
// 如果是其他设置变更,重新加载我们的设置
_isInitializing = true;
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop;
}
finally
{
_isInitializing = false;
}
}
private void SaveField<T>(string key, T value)

View File

@@ -30,7 +30,10 @@
FontWeight="SemiBold"
Margin="2,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontFamily="Consolas, Courier New, monospace"
MinWidth="42"
TextAlignment="Right"/>
</StackPanel>
<!-- 分隔符 -->
@@ -55,7 +58,10 @@
FontWeight="SemiBold"
Margin="2,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontFamily="Consolas, Courier New, monospace"
MinWidth="42"
TextAlignment="Right"/>
</StackPanel>
<!-- 网络类型图标 -->

View File

@@ -317,31 +317,32 @@ public partial class NetworkSpeedWidget : UserControl, IDesktopComponentWidget
private static string FormatSpeed(long bytesPerSecond)
{
// 根据数值大小决定显示格式始终保持3个字符宽度
// 例如: 1.23, 12.3, 123
// 根据数值大小选择合适的单位确保显示始终在1-99.9范围内
// 当数值达到100时自动切换到更大的单位
return bytesPerSecond switch
{
>= 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
>= 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
>= 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
>= 100L * 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
>= 100L * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
>= 100L * 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
>= 100 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"), // 100B+ 显示为 0.1K
_ => FormatWithThreeDigits(bytesPerSecond, "B")
};
}
/// <summary>
/// 格式化数字始终保持3个有效数字的显示宽度
/// 格式化数字始终保持3位数字+小数点,确保宽度恒定
/// 数值范围始终在1-99.9之间
/// </summary>
private static string FormatWithThreeDigits(double value, string unit)
{
// 根据数值大小决定小数位数,确保总宽度一致
// 始终保持3位数字小数点始终存在
// 数值范围: 0.0 - 99.9
// < 10: 显示两位小数 (如 1.23)
// 10-99: 显示一位小数 (如 12.3)
// >= 100: 显示整数 (如 123)
// >= 10: 显示一位小数 (如 12.3, 99.9)
string formatted = value switch
{
< 10 => $"{value:F2}",
< 100 => $"{value:F1}",
_ => $"{value:F0}"
< 10 => $"{value:F2}", // 1.23
_ => $"{value:F1}" // 12.3, 99.9
};
return formatted + unit;

View File

@@ -77,7 +77,9 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase)))
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
{
return;
}

View File

@@ -98,9 +98,13 @@
<Grid x:Name="DesktopPage"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RenderTransform>
<TranslateTransform />
</Grid.RenderTransform>
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" Easing="0.05,0.75,0.10,1.00" />
<DoubleTransition Property="TranslateTransform.X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Grid.Transitions>

View File

@@ -132,6 +132,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private double _currentDesktopCellGap;
private double _currentDesktopEdgeInset;
private string _gridSpacingPreset = "Relaxed";
private bool _isSlideAnimationActive;
private TranslateTransform? _desktopPageSlideTransform;
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
@@ -833,7 +835,103 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
if (_isSlideAnimationActive)
{
return;
}
SlideOutAndMinimizeAsync();
}
private TranslateTransform GetDesktopPageSlideTransform()
{
if (_desktopPageSlideTransform is not null)
{
return _desktopPageSlideTransform;
}
_desktopPageSlideTransform = DesktopPage.RenderTransform as TranslateTransform;
if (_desktopPageSlideTransform is null)
{
_desktopPageSlideTransform = new TranslateTransform();
DesktopPage.RenderTransform = _desktopPageSlideTransform;
}
return _desktopPageSlideTransform;
}
private async void SlideOutAndMinimizeAsync()
{
_isSlideAnimationActive = true;
DesktopPage.IsHitTestVisible = false;
var useSlide = IsSlideTransitionEnabled();
var slideTransform = GetDesktopPageSlideTransform();
if (useSlide)
{
slideTransform.X = Bounds.Width;
}
DesktopPage.Opacity = 0;
await Task.Delay(useSlide
? FluttermotionToken.Intro
: FluttermotionToken.Page);
if (!_isSlideAnimationActive)
{
return;
}
WindowState = WindowState.Minimized;
slideTransform.X = 0;
DesktopPage.Opacity = 1;
DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false;
}
public void PrepareEnterAnimation()
{
_isSlideAnimationActive = false;
var useSlide = IsSlideTransitionEnabled();
var slideTransform = GetDesktopPageSlideTransform();
var savedTransitions = DesktopPage.Transitions;
DesktopPage.Transitions = null;
DesktopPage.Opacity = 0;
if (useSlide)
{
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : 1920;
}
DesktopPage.Transitions = savedTransitions;
DesktopPage.IsHitTestVisible = false;
_isSlideAnimationActive = true;
}
public void PlayEnterAnimation()
{
var slideTransform = GetDesktopPageSlideTransform();
DesktopPage.Opacity = 1;
slideTransform.X = 0;
DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false;
}
private bool IsSlideTransitionEnabled()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return false;
}
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return snapshot.EnableSlideTransition;
}
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@@ -848,8 +946,18 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
return;
}
if (_isSlideAnimationActive)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_isSlideAnimationActive)
{
return;
}
if (WindowState is not (WindowState.Minimized or WindowState.FullScreen))
{
WindowState = WindowState.FullScreen;

View File

@@ -25,6 +25,26 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="启用三指滑动"
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面(实验性功能)">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Gesture" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="启用融合桌面"
Description="允许将组件放置在 Windows 系统桌面上(实验性功能,重启后生效)">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Apps" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableFusedDesktop}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />
<ui:SettingsExpander Header="开发者插件路径"

View File

@@ -14,13 +14,6 @@
Text="{Binding BasicHeader}"
Margin="0,0,0,4" />
<ui:SettingsExpander Header="启用三指滑动"
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{Binding LanguageHeader}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Settings" />
@@ -113,6 +106,17 @@
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Header="滑入滑出过渡效果"
Description="启用后,进入和退出桌面时使用滑入滑出动画(仅 Windows"
IsVisible="{Binding IsSlideTransitionAvailable}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ArrowRight" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableSlideTransition}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -1,6 +1,6 @@
#define MyAppName "LanMountainDesktop"
#define MyAppPublisher "LanMountainDesktop Team"
#define MyAppExeName "LanMountainDesktop.exe"
#define MyAppExeName "LanMountainDesktop.Launcher.exe"
#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
@@ -654,6 +654,9 @@ begin
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
LauncherPath: String;
AppDirPath: String;
begin
if CurStep = ssInstall then
begin
@@ -662,4 +665,27 @@ begin
Abort;
end;
end;
if CurStep = ssPostInstall then
begin
// 验证 Launcher 是否存在
LauncherPath := ExpandConstant('{app}\{#MyAppExeName}');
if not FileExists(LauncherPath) then
begin
MsgBox('安装验证失败: Launcher 可执行文件不存在。' + #13#10 +
'预期路径: ' + LauncherPath + #13#10 + #13#10 +
'请联系开发者报告此问题。', mbError, MB_OK);
Abort;
end;
// 验证至少存在一个 app-* 目录
AppDirPath := ExpandConstant('{app}\app-{#MyAppVersion}');
if not DirExists(AppDirPath) then
begin
MsgBox('安装验证失败: 应用版本目录不存在。' + #13#10 +
'预期路径: ' + AppDirPath + #13#10 + #13#10 +
'请联系开发者报告此问题。', mbError, MB_OK);
Abort;
end;
end;
end;

View File

@@ -784,12 +784,28 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
}
RefreshInstalledSnapshot();
SetStatus(
F(
"market.status.install_success_format",
"Plugin '{0}' has been staged. Restart the app to apply it.",
result.Manifest.Name),
SuccessBrush);
if (result.RestartRequired)
{
SetStatus(
F(
"market.status.upgrade_staged_format",
"Plugin '{0}' v{1} has been downloaded. Restart to complete the upgrade.",
result.Manifest.Name,
result.Manifest.Version),
WarningBrush);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
}
else
{
SetStatus(
F(
"market.status.install_success_format",
"Plugin '{0}' has been installed successfully.",
result.Manifest.Name),
SuccessBrush);
}
RebuildSurface();
}
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
@@ -1015,14 +1031,22 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
var leftParsed = AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion);
var rightParsed = AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion);
if (!leftParsed && !rightParsed)
{
leftVersion = new Version(0, 0, 0);
return 0;
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
if (!leftParsed)
{
rightVersion = new Version(0, 0, 0);
return -1;
}
if (!rightParsed)
{
return 1;
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
@@ -13,14 +14,16 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable
{
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new();
private readonly LauncherClient _launcherClient = new();
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly PendingPluginUpgradeService _pendingUpgradeService;
private readonly string _downloadsDirectory;
private readonly Version? _hostVersion;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
{
@@ -33,6 +36,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_downloadService = new ResumableDownloadService(_httpClient);
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
_pendingUpgradeService = new PendingPluginUpgradeService(runtime.PluginsDirectory);
_hostVersion = typeof(App).Assembly.GetName().Version;
}
public async Task<AirAppMarketInstallResult> InstallAsync(
@@ -41,18 +46,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
ArgumentNullException.ThrowIfNull(plugin);
if (OperatingSystem.IsWindows())
{
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
return new AirAppMarketInstallResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
}
}
Directory.CreateDirectory(_downloadsDirectory);
var sources = plugin.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
@@ -67,6 +60,39 @@ internal sealed class AirAppMarketInstallService : IDisposable
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
var compatibilityError = ValidateCompatibility(plugin);
if (!string.IsNullOrWhiteSpace(compatibilityError))
{
AppLogger.Warn("PluginMarket", $"Compatibility check failed. PluginId='{plugin.Id}'; Error='{compatibilityError}'.");
return new AirAppMarketInstallResult(false, null, compatibilityError);
}
var isUpgrade = IsPluginInstalled(plugin.Id);
if (isUpgrade)
{
return await InstallUpgradeAsync(plugin, sources, cancellationToken).ConfigureAwait(false);
}
return await InstallNewAsync(plugin, sources, cancellationToken).ConfigureAwait(false);
}
private async Task<AirAppMarketInstallResult> InstallNewAsync(
AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources,
CancellationToken cancellationToken)
{
if (OperatingSystem.IsWindows())
{
var launcherPath = ResolveLauncherPath();
if (!File.Exists(launcherPath))
{
return new AirAppMarketInstallResult(
false,
null,
$"Launcher executable was not found at '{launcherPath}'.");
}
}
var sourceErrors = new List<string>();
foreach (var source in sources)
{
@@ -93,6 +119,88 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketInstallResult(false, null, combinedMessage);
}
private async Task<AirAppMarketInstallResult> InstallUpgradeAsync(
AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources,
CancellationToken cancellationToken)
{
AppLogger.Info("PluginMarket", $"Detected upgrade scenario. Downloading package for deferred upgrade. PluginId='{plugin.Id}'.");
foreach (var source in sources)
{
var downloadResult = await DownloadPackageAsync(plugin, source, cancellationToken).ConfigureAwait(false);
if (downloadResult.Success && !string.IsNullOrWhiteSpace(downloadResult.PackagePath))
{
_pendingUpgradeService.AddPendingUpgrade(plugin.Id, downloadResult.PackagePath, plugin.Version);
AppLogger.Info(
"PluginMarket",
$"Upgrade staged for next restart. PluginId='{plugin.Id}'; Version='{plugin.Version}'; PackagePath='{downloadResult.PackagePath}'.");
var manifest = ReadManifestFromPackage(downloadResult.PackagePath);
return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true);
}
}
return new AirAppMarketInstallResult(
false,
null,
$"Failed to download upgrade package for plugin '{plugin.Id}' from all available sources.");
}
private bool IsPluginInstalled(string pluginId)
{
return _runtime.Catalog.Any(entry =>
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
}
private string? ValidateCompatibility(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(plugin.MinHostVersion))
{
if (!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return $"Plugin '{plugin.Id}' declares invalid minimum host version '{plugin.MinHostVersion}'.";
}
if (_hostVersion < minHostVersion)
{
return $"Plugin '{plugin.Id}' requires host version {plugin.MinHostVersion} or newer. Current host version is {_hostVersion}.";
}
}
if (!string.IsNullOrWhiteSpace(plugin.ApiVersion))
{
if (!AirAppMarketIndexDocument.TryParseVersion(plugin.ApiVersion, out var pluginApiVersion) ||
pluginApiVersion is null)
{
return $"Plugin '{plugin.Id}' declares invalid API version '{plugin.ApiVersion}'.";
}
var hostApiVersion = PluginSdkInfo.ApiVersion;
if (hostApiVersion is not null)
{
if (!AirAppMarketIndexDocument.TryParseVersion(hostApiVersion, out var hostApiVersionParsed) ||
hostApiVersionParsed is null)
{
AppLogger.Warn("PluginMarket", $"Host API version '{hostApiVersion}' could not be parsed. Skipping API version check.");
}
else if (pluginApiVersion.Major != hostApiVersionParsed.Major)
{
return $"Plugin '{plugin.Id}' uses incompatible API version {plugin.ApiVersion}. Host API version is {hostApiVersion}. Major version must match.";
}
}
}
return null;
}
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
@@ -126,16 +234,16 @@ internal sealed class AirAppMarketInstallService : IDisposable
PluginManifest manifest;
if (OperatingSystem.IsWindows())
{
var helperResult = await _helperClient.InstallPackageAsync(
var helperResult = await _launcherClient.InstallPackageAsync(
attemptPath,
_runtime.PluginsDirectory,
cancellationToken).ConfigureAwait(false);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
AppLogger.Error(
"PluginMarket",
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
}
@@ -255,9 +363,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketVerificationResult(true, null);
}
private static string ResolveHelperPath()
private static string ResolveLauncherPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
}
private static void TryDeleteFile(string path)
@@ -275,6 +383,71 @@ internal sealed class AirAppMarketInstallService : IDisposable
}
}
private async Task<DownloadPackageResult> DownloadPackageAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken)
{
var packagePath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
try
{
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
AppLogger.Info(
"PluginMarket",
$"Downloading upgrade package for '{plugin.Id}' from '{resolvedDownloadUrl}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, packagePath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, acquireResult.ErrorMessage);
}
var verificationResult = await VerifyPackageAsync(plugin, packagePath, cancellationToken).ConfigureAwait(false);
if (!verificationResult.Success)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, verificationResult.ErrorMessage);
}
return new DownloadPackageResult(true, packagePath, null);
}
catch (OperationCanceledException)
{
TryDeleteFile(packagePath);
throw;
}
catch (Exception ex)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, ex.Message);
}
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = System.IO.Compression.ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
public void Dispose()
{
_httpClient.Dispose();
@@ -299,4 +472,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
private sealed record AirAppMarketVerificationResult(
bool Success,
string? ErrorMessage);
private sealed record DownloadPackageResult(
bool Success,
string? PackagePath,
string? ErrorMessage);
}

View File

@@ -305,7 +305,8 @@ internal sealed record AirAppMarketLoadResult(
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginManifest? Manifest,
string? ErrorMessage);
string? ErrorMessage,
bool RestartRequired = false);
internal sealed class AirAppMarketIndexDocument
{

View File

@@ -685,6 +685,10 @@ public sealed class PluginRuntimeService : IDisposable
}
if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentAvaloniaUI", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentIcons.Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentIcons.Avalonia.Fluent", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "Material.Icons.Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase))
{
AddSharedAssembly(options, assembly);
@@ -773,11 +777,6 @@ public sealed class PluginRuntimeService : IDisposable
private void ApplyPendingPluginDeletions()
{
var pendingPaths = ReadPendingPluginDeletions();
if (pendingPaths.Count == 0)
{
return;
}
var remainingPaths = new List<string>();
foreach (var path in pendingPaths)
{
@@ -788,6 +787,41 @@ public sealed class PluginRuntimeService : IDisposable
}
SavePendingPluginDeletions(remainingPaths);
CleanupPendingDeletionDirectory();
}
private void CleanupPendingDeletionDirectory()
{
var pendingDeletionDir = Path.Combine(PluginsDirectory, ".pending-deletions");
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
// Ignore cleanup failures for pending deletions.
}
}
try
{
if (Directory.GetFiles(pendingDeletionDir).Length == 0 &&
Directory.GetDirectories(pendingDeletionDir).Length == 0)
{
Directory.Delete(pendingDeletionDir);
}
}
catch
{
// Ignore directory cleanup failures.
}
}
private string ResolvePluginRemovalTargetPath(PluginCatalogEntry entry)

View File

@@ -58,6 +58,7 @@
### 构建与运行
**开发模式 (推荐):**
```bash
# 还原依赖
dotnet restore
@@ -65,10 +66,18 @@ dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行桌面宿主
# 直接运行主程序 (跳过 Launcher,快速开发)
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
**生产模式 (完整流程):**
```bash
# 通过 Launcher 启动 (包含 OOBE、Splash、版本管理)
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
```
详细说明请参考 [开发文档](docs/DEVELOPMENT.md)。
### 运行测试
```bash
@@ -96,6 +105,7 @@ dotnet new lmd-plugin -n MyPlugin
```
LanMountainDesktop/
├── LanMountainDesktop/ # 桌面宿主应用
├── LanMountainDesktop.Launcher/ # 启动器 (OOBE、Splash、版本管理、更新)
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施

View File

@@ -7,6 +7,7 @@
| 路径 | 角色 |
| --- | --- |
| `LanMountainDesktop/` | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 |
| **`LanMountainDesktop.Launcher/`** | **启动器 - 负责 OOBE、Splash、版本管理、增量更新、插件安装** |
| `LanMountainDesktop.PluginSdk/` | 官方插件 SDK定义插件可依赖的公开接口与打包行为 |
| `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的稳定契约类型 |
| `LanMountainDesktop.Appearance/` | 主题、圆角、外观资源相关基础设施 |
@@ -14,12 +15,24 @@
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程与生命周期相关逻辑 |
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时支撑能力 |
| `LanMountainDesktop.Host.Abstractions/` | 宿主侧抽象接口 |
| `LanMountainDesktop.PluginsInstallHelper/` | 插件安装辅助程序与发布输出配套 |
| `LanMountainDesktop.PluginTemplate/` | `dotnet new lmd-plugin` 官方模板 |
| `LanMountainDesktop.Tests/` | 宿主与 SDK 的测试项目 |
### 宿主启动主线
**生产环境启动流程 (通过 Launcher):**
1. 用户启动 `LanMountainDesktop.Launcher.exe`
2. Launcher 扫描 `app-*` 目录,选择最佳版本 (优先 `.current` 标记,然后按版本号降序)
3. 首次启动显示 OOBE 引导 (`OobeWindow`)
4. 显示 Splash 启动动画 (`SplashWindow`)
5. 检查并应用待处理的更新 (`UpdateEngineService.ApplyPendingUpdate`)
6. 处理插件升级队列 (`PluginUpgradeQueueService`)
7. 启动主程序 `app-{version}/LanMountainDesktop.exe`
8. 清理标记为 `.destroy` 的旧版本
**主程序启动流程 (LanMountainDesktop.exe):**
启动入口在 `LanMountainDesktop/Program.cs`
1. 初始化日志、单实例锁和启动诊断
@@ -60,17 +73,124 @@
### 测试边界
`LanMountainDesktop.Tests/` 当前主要覆盖:
- 圆角与外观相关基线
- 组件放置与编辑数学
- 圆角与外观相关基础
- 组件放置与编辑数据
- 组件设置服务
- UI 异常防护
- 白板笔记持久化
涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试。
### Launcher 架构详解
#### 职责范围
`LanMountainDesktop.Launcher/` 作为应用的唯一入口,负责:
1. **OOBE (首次体验)** - 首次启动引导和欢迎页面
2. **Splash Screen** - 启动动画和加载进度显示
3. **版本管理** - 多版本并存、版本选择、版本回退
4. **应用更新** - 增量更新、静默更新、原子化更新
5. **插件管理** - 插件安装、插件更新队列处理
#### 核心服务
| 服务 | 职责 |
|------|------|
| `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 |
| `UpdateCheckService` | 调用 GitHub Release API 检查更新,支持 Stable/Preview 频道 |
| `UpdateEngineService` | 下载、验证、应用增量更新,支持原子化更新和回滚 |
| `LauncherFlowCoordinator` | 协调 OOBE → Splash → 更新 → 插件 → 启动主程序的完整流程 |
| `OobeStateService` | 管理首次运行状态 |
| `PluginInstallerService` | 处理 `.laapp` 插件包安装 |
| `PluginUpgradeQueueService` | 批量处理插件升级队列 |
#### 版本管理机制
**目录结构:**
```
安装根目录/
├── LanMountainDesktop.Launcher.exe ← 唯一入口
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe
│ └── ...
├── app-1.0.1/ ← 新版本
│ ├── .partial ← 下载中标记
│ └── ...
└── .launcher/ ← Launcher 数据
├── state/ ← OOBE 状态
├── update/incoming/ ← 更新缓存
└── snapshots/ ← 更新快照
```
**版本选择算法:**
1. 扫描所有 `app-*` 目录
2. 过滤掉带 `.destroy``.partial` 标记的目录
3. 优先选择带 `.current` 标记的版本
4. 如果没有 `.current`,选择版本号最高的
**版本标记文件:**
- `.current` - 标记当前使用的版本
- `.partial` - 标记下载未完成的版本 (更新失败时自动清理)
- `.destroy` - 标记待删除的旧版本 (下次启动时清理)
#### 更新流程
**增量更新:**
1. `UpdateCheckService` 调用 GitHub Release API
2. 根据更新频道 (Stable/Preview) 过滤版本
3. 下载 `delta-{old}-to-{new}.zip``files-{new}.json`
4. 创建 `app-{new}/` 目录并标记 `.partial`
5. 解压增量包,从旧版本复用未变更文件
6. 验证所有文件 SHA256
7. 删除 `.partial`,添加 `.current` 到新版本
8. 标记旧版本 `.destroy`
9. 保存更新快照到 `.launcher/snapshots/`
**原子化保证:**
- 更新过程中保持 `.partial` 标记
- 任何失败都会触发回滚
- 旧版本保留直到新版本验证通过
- 快照记录允许手动回退
**版本回退:**
```bash
LanMountainDesktop.Launcher.exe update rollback
```
回退会:
1. 读取最新的更新快照
2. 移除当前版本的 `.current` 标记
3. 添加 `.current` 到上一个版本
4. 标记当前版本为 `.destroy`
#### CI/CD 集成
**发布产物结构:**
```
GitHub Release Assets:
├── LanMountainDesktop-Setup-1.0.1-x64.exe (安装包)
├── app-1.0.1.zip (完整应用包)
├── delta-1.0.0-to-1.0.1.zip (增量包)
├── files-1.0.1.json (文件清单)
└── files-1.0.1.json.sig (RSA 签名)
```
**增量包生成:**
- `scripts/Generate-DeltaPackage.ps1` - 对比两个版本生成增量包
- `scripts/Sign-FileMap.ps1` - 对 `files.json` 进行 RSA 签名
- `.github/workflows/release.yml` - 自动生成并上传增量包
**安装器集成:**
- Inno Setup 脚本修改为安装 Launcher 到根目录
- 主程序安装到 `app-{version}/` 子目录
- 快捷方式指向 `LanMountainDesktop.Launcher.exe`
- 安装后验证 Launcher 和 app 目录存在
## English
This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests.
The runtime flow starts in `Program.cs`, proceeds into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.

335
docs/BUILD_AND_DEPLOY.md Normal file
View File

@@ -0,0 +1,335 @@
# 构建和部署指南
> LanMountainDesktop 完整构建、打包和发布流程
## 目录
- [本地构建](#本地构建)
- [发布构建](#发布构建)
- [生成安装包](#生成安装包)
- [CI/CD 流程](#cicd-流程)
- [手动发布](#手动发布)
## 本地构建
### 环境要求
- .NET SDK 10.0 或更高版本
- Windows 10/11 (推荐)
- Inno Setup 6 (仅生成安装包时需要)
### 快速构建
```bash
# 1. 还原依赖
dotnet restore LanMountainDesktop.slnx
# 2. 构建 Debug 版本
dotnet build LanMountainDesktop.slnx -c Debug
# 3. 运行主程序
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### 构建 Release 版本
```bash
dotnet build LanMountainDesktop.slnx -c Release
```
## 发布构建
### Windows (x64, 自包含)
```bash
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-x64 `
--self-contained `
-r win-x64 `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false
```
**发布后的目录结构:**
```
publish/windows-x64/
├── LanMountainDesktop.Launcher.exe ← 入口
├── app-{version}/ ← 主程序
│ ├── .current
│ ├── LanMountainDesktop.exe
│ └── ...
```
### Linux (x64)
```bash
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/linux-x64 `
--self-contained `
-r linux-x64
```
### macOS (arm64)
```bash
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/osx-arm64 `
--self-contained `
-r osx-arm64
```
## 生成安装包
### Windows 安装包 (Inno Setup)
**前提条件:**
```powershell
# 安装 Inno Setup
choco install innosetup -y
```
**生成安装包:**
```powershell
# 1. 发布应用
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-x64 `
--self-contained `
-r win-x64
# 2. 运行 Inno Setup 编译器
$version = "1.0.0"
$arch = "x64"
iscc.exe `
/DMyAppVersion=$version `
/DMyAppArch=$arch `
/DPublishDir="publish\windows-x64" `
/DMyOutputDir="build-installer" `
LanMountainDesktop\installer\LanMountainDesktop.iss
```
**输出:**
```
build-installer/
└── LanMountainDesktop-Setup-1.0.0-x64.exe
```
### Linux 包 (.deb)
```bash
# TODO: 添加 .deb 打包脚本
```
### macOS 包 (.dmg)
```bash
# TODO: 添加 .dmg 打包脚本
```
## CI/CD 流程
### GitHub Actions 工作流
项目使用 GitHub Actions 自动化构建和发布。
**触发条件:**
- 推送 `v*` 标签 (例如: `v1.0.0`)
- 手动触发 (workflow_dispatch)
**工作流文件:** `.github/workflows/release.yml`
### 发布流程
```
1. prepare job
├─ 解析版本号
└─ 设置构建变量
2. build-windows job
├─ 构建 x64 和 x86 版本
├─ 重组为 app-{version} 结构
├─ 生成增量包
├─ 生成 Inno Setup 安装包
└─ 上传 artifacts
3. build-linux job
├─ 构建 x64 版本
├─ 生成 .deb 包
└─ 上传 artifacts
4. build-macos job
├─ 构建 arm64 和 x64 版本
├─ 生成 .dmg 包
└─ 上传 artifacts
5. release job
├─ 下载所有 artifacts
├─ 创建 GitHub Release
└─ 上传所有安装包和增量包
```
### 发布产物
**GitHub Release Assets:**
```
LanMountainDesktop-v1.0.0/
├── LanMountainDesktop-Setup-1.0.0-x64.exe # Windows 安装包
├── LanMountainDesktop-Setup-1.0.0-x86.exe
├── LanMountainDesktop-1.0.0-linux-x64.deb # Linux 包
├── LanMountainDesktop-1.0.0-macos-arm64.dmg # macOS 包
├── app-1.0.0.zip # 完整应用包
├── delta-0.9.9-to-1.0.0.zip # 增量包
├── files-1.0.0.json # 文件清单
└── files-1.0.0.json.sig # RSA 签名
```
## 手动发布
### 1. 准备发布
```bash
# 1. 更新版本号
# 编辑 Directory.Build.props 中的 <Version>
# 2. 更新 CHANGELOG.md
# 记录本次发布的变更
# 3. 提交变更
git add .
git commit -m "chore: prepare release v1.0.0"
git push
```
### 2. 创建 Release 标签
```bash
# 创建标签
git tag v1.0.0
# 推送标签 (触发 CI)
git push origin v1.0.0
```
### 3. 等待 CI 完成
访问 GitHub Actions 页面,等待构建完成:
```
https://github.com/YourOrg/LanMountainDesktop/actions
```
### 4. 验证 Release
1. 访问 Releases 页面
2. 检查所有安装包是否上传成功
3. 下载并测试安装包
4. 验证增量更新功能
### 5. 发布公告
- 在 GitHub Release 中编辑发布说明
- 发布到社区/论坛
- 更新官网下载链接
## 增量包生成
### 手动生成增量包
```powershell
# 1. 准备两个版本的发布目录
dotnet publish ... -o ./publish/app-1.0.0
dotnet publish ... -o ./publish/app-1.0.1
# 2. 生成增量包
./scripts/Generate-DeltaPackage.ps1 `
-PreviousVersion "1.0.0" `
-CurrentVersion "1.0.1" `
-PreviousDir "./publish/app-1.0.0" `
-CurrentDir "./publish/app-1.0.1" `
-OutputDir "./delta-output"
# 3. 签名文件清单
./scripts/Sign-FileMap.ps1 `
-FilesJsonPath "./delta-output/files-1.0.1.json" `
-PrivateKeyPath "./private-key.pem"
```
**输出:**
```
delta-output/
├── delta-1.0.0-to-1.0.1.zip
├── files-1.0.1.json
└── files-1.0.1.json.sig
```
### 生成 RSA 密钥对
```powershell
# 生成私钥
openssl genrsa -out private-key.pem 2048
# 提取公钥
openssl rsa -in private-key.pem -pubout -out public-key.pem
```
**重要:**
- 私钥保存在安全位置 (GitHub Secrets)
- 公钥打包到 Launcher 中 (`.launcher/update/public-key.pem`)
## 版本号规范
遵循 [Semantic Versioning 2.0.0](https://semver.org/):
```
MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
例如:
- 1.0.0 (正式版)
- 1.0.1 (补丁版本)
- 1.1.0 (新功能)
- 2.0.0 (破坏性变更)
- 1.0.0-beta.1 (预览版)
- 1.0.0-rc.1 (候选版本)
```
### 版本号更新规则
- **MAJOR**: 破坏性 API 变更
- **MINOR**: 新功能,向后兼容
- **PATCH**: Bug 修复,向后兼容
- **PRERELEASE**: 预览版标识 (alpha, beta, rc)
## 故障排除
### 构建失败
**问题**: `error NU1102: Unable to find package`
**解决**:
```bash
dotnet restore --force
dotnet nuget locals all --clear
```
### 发布失败
**问题**: Launcher 目录不存在
**解决**: 检查 `LanMountainDesktop.csproj` 中的 `CopyLauncherToPublish` 目标是否正确执行。
### 安装包生成失败
**问题**: Inno Setup 找不到文件
**解决**: 确保 `PublishDir` 路径正确,且包含 `app-{version}/` 目录结构。
## 相关文档
- [开发文档](DEVELOPMENT.md)
- [Launcher 架构](LAUNCHER.md)
- [更新系统](UPDATE_SYSTEM.md)
- [故障排除](TROUBLESHOOTING.md)

View File

@@ -20,10 +20,32 @@ dotnet build LanMountainDesktop.slnx -c Debug
#### 运行桌面宿主
**开发模式 (直接运行主程序,跳过 Launcher):**
```bash
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
**生产模式 (通过 Launcher 启动):**
```bash
# 先构建 Launcher
dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug
# 通过 Launcher 启动主程序
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
```
**Launcher 其他命令:**
```bash
# 检查更新
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
# 安装插件
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install <path-to-plugin.laapp>
# 版本回退
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
```
#### 运行测试
```bash
@@ -33,13 +55,18 @@ dotnet test LanMountainDesktop.slnx -c Debug
### 常见工作区域
- 宿主应用:`LanMountainDesktop/`
- **Launcher (启动器)`LanMountainDesktop.Launcher/`**
- Plugin SDK`LanMountainDesktop.PluginSdk/`
- 共享契约:`LanMountainDesktop.Shared.Contracts/`
- 测试:`LanMountainDesktop.Tests/`
- 插件打包脚本:`scripts/Pack-PluginPackages.ps1`
- **增量更新脚本:`scripts/Generate-DeltaPackage.ps1`, `scripts/Sign-FileMap.ps1`**
### 调试建议
- **Launcher 启动问题优先看 `LanMountainDesktop.Launcher/Program.cs``Services/LauncherFlowCoordinator.cs`**
- **版本管理问题优先看 `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`**
- **更新系统问题优先看 `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs``UpdateCheckService.cs`**
- 启动问题优先看 `LanMountainDesktop/Program.cs``LanMountainDesktop/App.axaml.cs`
- 设置窗口和设置页问题优先看 `LanMountainDesktop/Views/``ViewModels/` 与相关 `Services/`
- 插件加载与安装问题优先看 `LanMountainDesktop/plugins/`
@@ -74,8 +101,68 @@ dotnet test LanMountainDesktop.slnx -c Debug
- 需求与实施拆解更新到 `.trae/specs/`
- AI 协作入口和代码地图更新到 `AGENTS.md``docs/ai/`
### Launcher 架构说明
LanMountainDesktop 使用 Launcher 作为唯一入口,负责版本管理、更新和启动主程序。
#### 目录结构
安装后的目录结构:
```
C:\Program Files\LanMountainDesktop\
├── LanMountainDesktop.Launcher.exe ← 唯一入口
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe
│ └── ... (所有依赖)
├── app-1.0.1/ ← 新版本
│ ├── .partial ← 下载中标记
│ └── ...
└── .launcher/ ← Launcher 数据
├── state/ ← OOBE 状态
├── update/incoming/ ← 更新缓存
└── snapshots/ ← 更新快照
```
#### 版本标记文件
- `.current` - 标记当前使用的版本
- `.partial` - 标记下载未完成的版本
- `.destroy` - 标记待删除的旧版本
#### 启动流程
1. 用户启动 `LanMountainDesktop.Launcher.exe`
2. Launcher 扫描 `app-*` 目录,选择最佳版本
3. 如果是首次启动,显示 OOBE 引导
4. 显示 Splash 启动动画
5. 检查并应用待处理的更新
6. 处理插件升级队列
7. 启动主程序 `app-{version}/LanMountainDesktop.exe`
8. 清理标记为 `.destroy` 的旧版本
#### 更新流程
1. Launcher 调用 GitHub Release API 检查更新
2. 根据更新频道(Stable/Preview)过滤版本
3. 下载增量包到 `app-{new_version}/` 并标记 `.partial`
4. 验证文件完整性(SHA256)
5. 删除 `.partial`,添加 `.current` 到新版本
6. 标记旧版本 `.destroy`
7. 下次启动时自动清理
#### 版本回退
```bash
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
```
回退会切换到上一个有效版本,并保留快照记录。
## English
Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is `dotnet restore`, `dotnet build LanMountainDesktop.slnx -c Debug`, `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`, and `dotnet test LanMountainDesktop.slnx -c Debug`.
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.

549
docs/LAUNCHER.md Normal file
View File

@@ -0,0 +1,549 @@
# Launcher 架构文档
> LanMountainDesktop.Launcher - 应用启动器和版本管理系统
## 目录
- [概述](#概述)
- [职责范围](#职责范围)
- [架构设计](#架构设计)
- [核心服务](#核心服务)
- [版本管理](#版本管理)
- [启动流程](#启动流程)
- [命令行接口](#命令行接口)
- [开发指南](#开发指南)
## 概述
Launcher 是 LanMountainDesktop 的唯一入口点,负责:
- 首次体验引导 (OOBE)
- 启动动画 (Splash Screen)
- 多版本管理和选择
- 应用更新 (增量更新、原子化更新)
- 插件安装和升级
- 版本回退
**设计理念**: 参考 ClassIsland 项目,实现原子化的多版本管理和随时版本回退能力。
## 职责范围
### 1. OOBE (Out-of-Box Experience)
- 首次启动引导
- 欢迎页面
- 初始设置向导
### 2. Splash Screen
- 启动动画
- 加载进度显示
- 品牌展示
### 3. 版本管理
- 多版本并存 (`app-{version}/` 目录)
- 版本选择算法
- 版本标记系统 (`.current`, `.partial`, `.destroy`)
- 旧版本自动清理
### 4. 应用更新
- GitHub Release API 集成
- 更新频道管理 (Stable/Preview)
- 增量更新下载
- 原子化更新应用
- 签名验证
- 版本回退
### 5. 插件管理
- 插件安装 (`.laapp` 包)
- 插件更新检查
- 插件升级队列处理
## 架构设计
### 目录结构
**安装后的目录结构:**
```
C:\Program Files\LanMountainDesktop\
├── LanMountainDesktop.Launcher.exe ← 唯一入口
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe
│ ├── LanMountainDesktop.dll
│ └── ... (所有依赖)
├── app-1.0.1/ ← 新版本
│ ├── .partial ← 下载中标记
│ └── ...
├── app-0.9.9/ ← 旧版本
│ ├── .destroy ← 待删除标记
│ └── ...
└── .launcher/ ← Launcher 数据目录
├── state/
│ └── first_run_completed ← OOBE 完成标记
├── update/
│ ├── incoming/ ← 更新缓存
│ │ ├── files.json
│ │ ├── files.json.sig
│ │ └── update.zip
│ └── public-key.pem ← RSA 公钥
└── snapshots/ ← 更新快照
└── {snapshot-id}.json
```
### 版本标记文件
| 文件名 | 作用 | 创建时机 | 删除时机 |
|--------|------|----------|----------|
| `.current` | 标记当前使用的版本 | 更新完成后 | 新版本激活时 |
| `.partial` | 标记下载未完成的版本 | 开始下载时 | 下载完成验证通过后 |
| `.destroy` | 标记待删除的旧版本 | 新版本激活时 | 目录删除后 |
## 核心服务
### DeploymentLocator
**职责**: 扫描和定位版本目录,选择最佳版本
**关键方法**:
```csharp
// 查找当前部署目录
string? FindCurrentDeploymentDirectory()
// 解析主程序可执行文件路径
string? ResolveHostExecutablePath()
// 获取当前版本号
string GetCurrentVersion()
// 构建下一个部署目录路径
string BuildNextDeploymentDirectory(string targetVersion)
// 清理标记为 .destroy 的目录
void CleanupDestroyedDeployments()
```
**版本选择算法**:
1. 扫描所有 `app-*` 目录
2. 过滤掉带 `.destroy``.partial` 标记的目录
3. 优先选择带 `.current` 标记的版本
4. 如果没有 `.current`,选择版本号最高的
### UpdateCheckService
**职责**: 检查 GitHub Release 更新
**关键方法**:
```csharp
// 检查更新
Task<UpdateCheckResult> CheckForUpdateAsync(
string currentVersion,
UpdateChannel channel,
CancellationToken cancellationToken = default)
```
**更新频道**:
- `Stable` - 只检查 `prerelease=false` 的版本
- `Preview` - 检查所有版本 (包括 `prerelease=true`)
### UpdateEngineService
**职责**: 下载、验证、应用更新
**关键方法**:
```csharp
// 检查待处理的更新
LauncherResult CheckPendingUpdate()
// 下载更新
Task<LauncherResult> DownloadAsync(
string manifestUrl,
string signatureUrl,
string archiveUrl,
CancellationToken cancellationToken)
// 应用待处理的更新
LauncherResult ApplyPendingUpdate()
// 回退到上一个版本
LauncherResult RollbackLatest()
// 清理待删除的部署
void CleanupDestroyedDeployments()
```
### LauncherFlowCoordinator
**职责**: 协调完整的启动流程
**启动流程**:
1. 清理待删除的旧版本
2. 检查是否首次运行,显示 OOBE
3. 显示 Splash 窗口
4. 应用待处理的更新
5. 处理插件升级队列
6. 启动主程序
7. 关闭 Splash 窗口
### OobeStateService
**职责**: 管理首次运行状态
**关键方法**:
```csharp
// 检查是否首次运行
bool IsFirstRun()
// 标记 OOBE 已完成
void MarkCompleted()
```
### PluginInstallerService
**职责**: 处理插件安装
**关键方法**:
```csharp
// 安装插件包
Task<PluginInstallResult> InstallAsync(
string packagePath,
string targetDirectory,
CancellationToken cancellationToken = default)
```
### PluginUpgradeQueueService
**职责**: 批量处理插件升级队列
**关键方法**:
```csharp
// 应用待处理的插件升级
LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
```
## 版本管理
### 版本选择算法详解
```csharp
public string? FindCurrentDeploymentDirectory()
{
var candidates = Directory.GetDirectories(rootDir, "app-*");
// 1. 过滤无效版本
var validCandidates = candidates
.Where(path =>
!File.Exists(Path.Combine(path, ".destroy")) &&
!File.Exists(Path.Combine(path, ".partial")))
.ToList();
// 2. 优先选择带 .current 标记的
var withMarkers = validCandidates
.Where(path => File.Exists(Path.Combine(path, ".current")))
.OrderByDescending(path => ParseVersion(path))
.FirstOrDefault();
if (withMarkers != null)
return withMarkers;
// 3. 选择版本号最高的
return validCandidates
.OrderByDescending(path => ParseVersion(path))
.FirstOrDefault();
}
```
### 版本激活流程
```csharp
private void ActivateDeployment(string fromDeployment, string toDeployment)
{
// 1. 在新版本添加 .current 标记
File.WriteAllText(Path.Combine(toDeployment, ".current"), string.Empty);
// 2. 移除旧版本的 .current 标记
var fromCurrent = Path.Combine(fromDeployment, ".current");
if (File.Exists(fromCurrent))
File.Delete(fromCurrent);
// 3. 标记旧版本为待删除
File.WriteAllText(Path.Combine(fromDeployment, ".destroy"), string.Empty);
// 4. 移除新版本的 .partial 标记 (如果有)
var toPartial = Path.Combine(toDeployment, ".partial");
if (File.Exists(toPartial))
File.Delete(toPartial);
}
```
### 版本清理流程
```csharp
public void CleanupDestroyedDeployments()
{
var destroyedDirs = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败 (可能文件被占用)
// 下次启动时再试
}
}
}
```
## 启动流程
### 完整启动流程图
```
用户启动 Launcher.exe
清理旧版本 (.destroy 目录)
首次运行? ──Yes→ 显示 OOBE 窗口
↓ No
显示 Splash 窗口
检查待处理的更新
有更新? ──Yes→ 应用更新 (原子化)
↓ No
处理插件升级队列
选择最佳版本 (DeploymentLocator)
启动主程序 (Process.Start)
关闭 Splash 窗口
Launcher 退出
```
### 代码流程
**Program.cs**:
```csharp
static async Task<int> Main(string[] args)
{
var commandContext = CommandContext.FromArgs(args);
// 处理 CLI 命令
if (commandContext.Command != "launch")
return await Commands.RunCliCommandAsync(commandContext);
// 启动 Avalonia 应用
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
```
**App.axaml.cs**:
```csharp
public override void OnFrameworkInitializationCompleted()
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateCheckService = new UpdateCheckService("owner", "repo");
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
_ = RunCoordinatorAsync(desktop, coordinator);
}
```
**LauncherFlowCoordinator.RunAsync()**:
```csharp
public async Task<LauncherResult> RunAsync()
{
// 1. 清理旧版本
_deploymentLocator.CleanupDestroyedDeployments();
// 2. OOBE
if (_oobeStateService.IsFirstRun())
{
foreach (var step in _oobeSteps)
await step.RunAsync(CancellationToken.None);
}
// 3. Splash
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
try
{
// 4. 应用更新
var updateResult = _updateEngine.ApplyPendingUpdate();
if (!updateResult.Success)
return updateResult;
// 5. 插件升级
var pluginsDir = Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService)
.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
return queueResult;
// 6. 启动主程序
var hostResult = LaunchHost();
if (!hostResult.Success)
return hostResult;
return new LauncherResult { Success = true };
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
}
}
```
## 命令行接口
### launch - 启动应用
```bash
LanMountainDesktop.Launcher.exe launch
```
启动完整流程: OOBE → Splash → 更新 → 插件 → 主程序
### update check - 检查更新
```bash
LanMountainDesktop.Launcher.exe update check
```
检查 GitHub Release 是否有新版本。
### update download - 下载更新
```bash
LanMountainDesktop.Launcher.exe update download --version 1.0.1
```
下载指定版本的更新包。
### update apply - 应用更新
```bash
LanMountainDesktop.Launcher.exe update apply
```
应用已下载的更新 (原子化操作)。
### update rollback - 版本回退
```bash
LanMountainDesktop.Launcher.exe update rollback
```
回退到上一个有效版本。
### plugin install - 安装插件
```bash
LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp>
```
安装 `.laapp` 插件包。
## 开发指南
### 本地调试
**直接运行 Launcher:**
```bash
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
```
**调试特定命令:**
```bash
# 检查更新
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
# 版本回退
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
```
### 模拟多版本环境
```bash
# 1. 发布主程序
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Debug -o ./test-deploy/app-1.0.0
# 2. 创建 .current 标记
New-Item -ItemType File -Path ./test-deploy/app-1.0.0/.current
# 3. 复制 Launcher 到根目录
Copy-Item LanMountainDesktop.Launcher/bin/Debug/net10.0/* ./test-deploy/
# 4. 运行 Launcher
./test-deploy/LanMountainDesktop.Launcher.exe launch
```
### 测试更新流程
```bash
# 1. 创建两个版本
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.0
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -o ./test-deploy/app-1.0.1
# 2. 生成增量包
pwsh ./scripts/Generate-DeltaPackage.ps1 `
-PreviousVersion "1.0.0" `
-CurrentVersion "1.0.1" `
-PreviousDir "./test-deploy/app-1.0.0" `
-CurrentDir "./test-deploy/app-1.0.1" `
-OutputDir "./test-deploy/.launcher/update/incoming"
# 3. 测试应用更新
./test-deploy/LanMountainDesktop.Launcher.exe update apply
```
### 添加新的 OOBE 步骤
1. 实现 `IOobeStep` 接口:
```csharp
public class MyOobeStep : IOobeStep
{
public async Task RunAsync(CancellationToken cancellationToken)
{
// 显示 OOBE 窗口
// 等待用户完成
}
}
```
2.`LauncherFlowCoordinator` 中注册:
```csharp
_oobeSteps = [
new WelcomeOobeStep(_oobeStateService),
new MyOobeStep() // 添加新步骤
];
```
### 自定义更新源
修改 `App.axaml.cs` 中的 GitHub 仓库信息:
```csharp
var updateCheckService = new UpdateCheckService(
"YourOrg", // GitHub 组织/用户名
"YourRepo" // 仓库名
);
```
## 相关文档
- [更新系统详细文档](UPDATE_SYSTEM.md)
- [构建和部署指南](BUILD_AND_DEPLOY.md)
- [架构文档](ARCHITECTURE.md)
- [开发文档](DEVELOPMENT.md)

686
docs/PLUGIN_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,686 @@
# 插件开发指南
> 为 LanMountainDesktop 开发自定义插件
## 目录
- [快速开始](#快速开始)
- [插件架构](#插件架构)
- [创建插件](#创建插件)
- [插件生命周期](#插件生命周期)
- [添加组件](#添加组件)
- [添加设置页](#添加设置页)
- [使用服务](#使用服务)
- [打包和发布](#打包和发布)
- [最佳实践](#最佳实践)
## 快速开始
### 安装插件模板
```bash
# 安装官方插件模板
dotnet new install LanMountainDesktop.PluginTemplate
# 查看可用模板
dotnet new list | findstr lmd
```
### 创建新插件
```bash
# 创建插件项目
dotnet new lmd-plugin -n MyAwesomePlugin
# 进入项目目录
cd MyAwesomePlugin
# 还原依赖
dotnet restore
# 构建插件
dotnet build
```
### 项目结构
```
MyAwesomePlugin/
├── MyAwesomePlugin.csproj # 项目文件
├── Plugin.cs # 插件入口
├── Components/ # 组件目录
│ └── MyComponent.cs
├── Views/ # 视图目录
│ └── MyComponentView.axaml
├── ViewModels/ # 视图模型
│ └── MyComponentViewModel.cs
├── Settings/ # 设置页
│ └── MySettingsPage.axaml
└── plugin.json # 插件清单
```
## 插件架构
### 插件 SDK 版本
当前 SDK 版本: **4.0.1**
```xml
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="4.0.1" />
```
### 插件清单 (plugin.json)
```json
{
"Id": "com.example.myawesomeplugin",
"Name": "My Awesome Plugin",
"Version": "1.0.0",
"Author": "Your Name",
"Description": "A plugin that does awesome things",
"MinHostVersion": "1.0.0",
"Dependencies": [],
"Permissions": [
"FileSystem.Read",
"Network.Access"
]
}
```
### 核心接口
**IPlugin** - 插件入口接口:
```csharp
public interface IPlugin
{
string Id { get; }
string Name { get; }
string Version { get; }
Task InitializeAsync(IPluginContext context);
Task ShutdownAsync();
}
```
**IPluginContext** - 插件上下文:
```csharp
public interface IPluginContext
{
string PluginDirectory { get; }
IServiceProvider Services { get; }
ILogger Logger { get; }
ISettingsService Settings { get; }
}
```
## 创建插件
### 1. 实现插件入口
```csharp
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts;
namespace MyAwesomePlugin;
public class Plugin : IPlugin
{
public string Id => "com.example.myawesomeplugin";
public string Name => "My Awesome Plugin";
public string Version => "1.0.0";
private IPluginContext? _context;
public async Task InitializeAsync(IPluginContext context)
{
_context = context;
// 注册组件
var componentRegistry = context.Services.GetService<IComponentRegistry>();
componentRegistry?.RegisterComponent<MyComponent>();
// 注册设置页
var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
settingsRegistry?.RegisterPage<MySettingsPage>("我的插件设置");
// 初始化逻辑
context.Logger.LogInformation("Plugin initialized");
await Task.CompletedTask;
}
public async Task ShutdownAsync()
{
// 清理资源
_context?.Logger.LogInformation("Plugin shutting down");
await Task.CompletedTask;
}
}
```
### 2. 配置项目文件
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- 插件元数据 -->
<PluginId>com.example.myawesomeplugin</PluginId>
<PluginName>My Awesome Plugin</PluginName>
<PluginVersion>1.0.0</PluginVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="4.0.1" />
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>
<!-- 复制 plugin.json 到输出目录 -->
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
```
## 插件生命周期
### 生命周期阶段
```
1. 发现 (Discovery)
2. 加载 (Load)
├─ 加载程序集
├─ 验证依赖
└─ 创建插件实例
3. 初始化 (Initialize)
├─ 调用 InitializeAsync()
├─ 注册组件
├─ 注册设置页
└─ 初始化服务
4. 运行 (Running)
├─ 组件渲染
├─ 事件处理
└─ 服务调用
5. 关闭 (Shutdown)
├─ 调用 ShutdownAsync()
├─ 清理资源
└─ 卸载程序集
```
### 生命周期钩子
```csharp
public class Plugin : IPlugin
{
// 插件加载后立即调用
public async Task InitializeAsync(IPluginContext context)
{
// 注册组件、服务、设置页
// 初始化资源
}
// 插件卸载前调用
public async Task ShutdownAsync()
{
// 保存状态
// 释放资源
// 取消订阅
}
}
```
## 添加组件
### 1. 定义组件类
```csharp
using LanMountainDesktop.PluginSdk.Components;
using LanMountainDesktop.Shared.Contracts;
namespace MyAwesomePlugin.Components;
[Component(
Id = "com.example.myawesomeplugin.mycomponent",
Name = "我的组件",
Description = "一个很棒的组件",
Category = "工具",
Icon = "avares://MyAwesomePlugin/Assets/icon.png"
)]
public class MyComponent : ComponentBase
{
public override string Id => "com.example.myawesomeplugin.mycomponent";
public override string Name => "我的组件";
// 组件设置
private string _message = "Hello, World!";
public string Message
{
get => _message;
set => SetProperty(ref _message, value);
}
// 组件初始化
public override Task InitializeAsync()
{
// 加载设置
Message = Settings.GetValue("Message", "Hello, World!");
return Task.CompletedTask;
}
// 组件更新 (定时调用)
public override Task UpdateAsync()
{
// 更新组件数据
return Task.CompletedTask;
}
}
```
### 2. 创建组件视图
**MyComponentView.axaml:**
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyAwesomePlugin.ViewModels"
x:Class="MyAwesomePlugin.Views.MyComponentView"
x:DataType="vm:MyComponentViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Component.Name}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Text="{Binding Component.Message}"
TextWrapping="Wrap" />
<Button Content="点击我"
Command="{Binding ClickCommand}" />
</StackPanel>
</Border>
</UserControl>
```
**MyComponentView.axaml.cs:**
```csharp
using Avalonia.Controls;
namespace MyAwesomePlugin.Views;
public partial class MyComponentView : UserControl
{
public MyComponentView()
{
InitializeComponent();
}
}
```
### 3. 创建视图模型
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace MyAwesomePlugin.ViewModels;
public partial class MyComponentViewModel : ObservableObject
{
[ObservableProperty]
private MyComponent _component;
public MyComponentViewModel(MyComponent component)
{
_component = component;
}
[RelayCommand]
private void Click()
{
Component.Message = "按钮被点击了!";
}
}
```
### 4. 注册组件
```csharp
public async Task InitializeAsync(IPluginContext context)
{
var componentRegistry = context.Services.GetService<IComponentRegistry>();
// 注册组件
componentRegistry?.RegisterComponent<MyComponent>(
componentFactory: () => new MyComponent(),
viewFactory: (component) => new MyComponentView
{
DataContext = new MyComponentViewModel((MyComponent)component)
}
);
}
```
## 添加设置页
### 1. 创建设置页视图
**MySettingsPage.axaml:**
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MyAwesomePlugin.Settings.MySettingsPage">
<StackPanel Spacing="16" Margin="24">
<TextBlock Text="我的插件设置"
FontSize="24"
FontWeight="Bold" />
<StackPanel Spacing="8">
<TextBlock Text="消息内容:" />
<TextBox x:Name="MessageTextBox"
Watermark="输入消息..." />
</StackPanel>
<Button Content="保存"
Click="SaveButton_Click" />
</StackPanel>
</UserControl>
```
**MySettingsPage.axaml.cs:**
```csharp
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.PluginSdk;
namespace MyAwesomePlugin.Settings;
public partial class MySettingsPage : UserControl
{
private readonly ISettingsService _settings;
public MySettingsPage(ISettingsService settings)
{
InitializeComponent();
_settings = settings;
// 加载设置
MessageTextBox.Text = _settings.GetValue("Message", "Hello, World!");
}
private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
// 保存设置
_settings.SetValue("Message", MessageTextBox.Text);
// 显示提示
// TODO: 显示保存成功提示
}
}
```
### 2. 注册设置页
```csharp
public async Task InitializeAsync(IPluginContext context)
{
var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
settingsRegistry?.RegisterPage(
title: "我的插件",
category: "插件",
pageFactory: () => new MySettingsPage(context.Settings)
);
}
```
## 使用服务
### 可用服务
**ILogger** - 日志服务:
```csharp
context.Logger.LogInformation("信息日志");
context.Logger.LogWarning("警告日志");
context.Logger.LogError("错误日志");
```
**ISettingsService** - 设置服务:
```csharp
// 读取设置
var value = context.Settings.GetValue("Key", "DefaultValue");
// 写入设置
context.Settings.SetValue("Key", "NewValue");
// 监听设置变化
context.Settings.SettingChanged += (sender, e) =>
{
if (e.Key == "Key")
{
// 设置已变更
}
};
```
**INotificationService** - 通知服务:
```csharp
var notificationService = context.Services.GetService<INotificationService>();
notificationService?.ShowNotification(
title: "通知标题",
message: "通知内容",
type: NotificationType.Information
);
```
**IHttpClientFactory** - HTTP 客户端:
```csharp
var httpFactory = context.Services.GetService<IHttpClientFactory>();
var httpClient = httpFactory?.CreateClient();
var response = await httpClient.GetStringAsync("https://api.example.com/data");
```
## 打包和发布
### 1. 构建插件
```bash
dotnet build -c Release
```
### 2. 打包为 .laapp
```bash
# 使用官方打包脚本
pwsh ./scripts/Pack-PluginPackages.ps1 -PluginProject ./MyAwesomePlugin/MyAwesomePlugin.csproj
# 或手动打包
cd MyAwesomePlugin/bin/Release/net10.0
zip -r MyAwesomePlugin-1.0.0.laapp *
```
### 3. 测试插件
```bash
# 安装插件
LanMountainDesktop.Launcher.exe plugin install MyAwesomePlugin-1.0.0.laapp
# 启动应用测试
LanMountainDesktop.Launcher.exe launch
```
### 4. 发布插件
**选项 1: GitHub Release**
1. 创建 GitHub 仓库
2. 上传 `.laapp` 文件到 Release
3. 用户可以手动下载安装
**选项 2: 插件市场** (如果可用)
1. 提交插件到官方市场
2. 等待审核
3. 用户可以在应用内浏览和安装
## 最佳实践
### 性能优化
1. **避免阻塞 UI 线程:**
```csharp
// 错误
public override Task UpdateAsync()
{
Thread.Sleep(1000); // 阻塞!
return Task.CompletedTask;
}
// 正确
public override async Task UpdateAsync()
{
await Task.Delay(1000);
}
```
2. **使用异步 API:**
```csharp
// 使用 async/await
var data = await httpClient.GetStringAsync(url);
```
3. **缓存数据:**
```csharp
private string? _cachedData;
private DateTime _cacheTime;
public async Task<string> GetDataAsync()
{
if (_cachedData != null && DateTime.Now - _cacheTime < TimeSpan.FromMinutes(5))
return _cachedData;
_cachedData = await FetchDataAsync();
_cacheTime = DateTime.Now;
return _cachedData;
}
```
### 资源管理
1. **实现 IDisposable:**
```csharp
public class MyComponent : ComponentBase, IDisposable
{
private HttpClient? _httpClient;
public void Dispose()
{
_httpClient?.Dispose();
}
}
```
2. **取消订阅事件:**
```csharp
public override Task ShutdownAsync()
{
context.Settings.SettingChanged -= OnSettingChanged;
return Task.CompletedTask;
}
```
### 错误处理
1. **捕获异常:**
```csharp
public override async Task UpdateAsync()
{
try
{
await FetchDataAsync();
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "Failed to fetch data");
// 显示错误提示给用户
}
}
```
2. **验证输入:**
```csharp
public void SetUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
throw new ArgumentException("URL cannot be empty", nameof(url));
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
throw new ArgumentException("Invalid URL format", nameof(url));
_url = url;
}
```
### 本地化
1. **使用资源文件:**
```csharp
// Resources/Strings.resx
// Name: ComponentName, Value: My Component
public override string Name => Resources.Strings.ComponentName;
```
2. **支持多语言:**
```xml
<!-- Resources/Strings.zh-CN.resx -->
<data name="ComponentName" xml:space="preserve">
<value>我的组件</value>
</data>
```
### 安全性
1. **验证用户输入:**
```csharp
// 防止路径遍历
var safePath = Path.GetFullPath(Path.Combine(pluginDirectory, userInput));
if (!safePath.StartsWith(pluginDirectory))
throw new SecurityException("Invalid path");
```
2. **使用 HTTPS:**
```csharp
// 强制使用 HTTPS
if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
throw new SecurityException("Only HTTPS URLs are allowed");
```
## 示例插件
查看官方示例插件:
- **天气组件** - 显示天气信息
- **倒计时组件** - 倒计时功能
- **RSS 阅读器** - 订阅和显示 RSS 源
仓库: https://github.com/YourOrg/LanMountainDesktop.SamplePlugin
## 相关文档
- [Plugin SDK v4 迁移指南](PLUGIN_SDK_V4_MIGRATION.md)
- [组件开发指南](COMPONENT_DEVELOPMENT.md)
- [API 参考](API_REFERENCE.md)
- [架构文档](ARCHITECTURE.md)

View File

@@ -0,0 +1,118 @@
# 🧭 阑山桌面插件开发文档导航
欢迎来到 **LanMountainDesktop阑山桌面** 插件开发文档!
这套文档将帮助你从零开始,一步步掌握插件开发的完整流程,最终发布你的作品到插件市场。
---
## 📖 文档概述
**目标读者:**
- 有一定 .NET/C# 基础的开发者
- 熟悉或愿意学习 Avalonia UI 框架的开发者
- 想要为阑山桌面扩展功能的创意开发者
**你能学到什么:**
- 🚀 快速搭建插件开发环境
- 🧩 创建桌面组件Widgets
- ⚙️ 集成设置页面
- 🎨 适配主题和外观
- 🐛 调试和故障排除
- 🚀 CI/CD 自动化构建
- 📦 发布到插件市场
---
## 🛤️ 推荐阅读路径
### 🌱 新手路径(从零开始)
如果你从未开发过阑山桌面插件,请按以下顺序阅读:
1. **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** - 安装必要工具和模板
2. **[02-三分钟创建第一个插件](01-快速开始/02-三分钟创建第一个插件.md)** - 快速上手,建立信心
3. **[03-插件项目结构详解](01-快速开始/03-插件项目结构详解.md)** - 理解项目组成
4. **[04-调试运行指南](01-快速开始/04-调试运行指南.md)** - 学会调试技巧
5. **[01-插件生命周期](02-核心概念与原理/01-插件生命周期.md)** - 理解运行原理
6. **[02-桌面组件系统](02-核心概念与原理/02-桌面组件系统.md)** - 创建你的第一个组件
7. **[01-开发天气组件](04-实战案例/01-开发天气组件.md)** - 完整实战案例
**预计时间:** 2-3 小时即可开发出第一个可用插件
### 🚀 有经验路径(已有 .NET/Avalonia 基础)
如果你已有相关经验,可以跳过基础部分:
1. **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** - 快速配置环境
2. **[02-核心概念与原理/](02-核心概念与原理/)** - 了解阑山桌面的特殊机制
3. **[03-API实践指南/](03-API实践指南/)** - 查阅具体 API 用法
4. **[04-实战案例/](04-实战案例/)** - 参考完整示例
---
## 🔍 快速问题索引
| 我想知道... | 查看文档 |
|------------|---------|
| 如何搭建开发环境? | [01-开发环境准备](01-快速开始/01-开发环境准备.md) |
| 如何创建第一个插件? | [02-三分钟创建第一个插件](01-快速开始/02-三分钟创建第一个插件.md) |
| plugin.json 各字段含义? | [03-插件项目结构详解](01-快速开始/03-插件项目结构详解.md) |
| 如何调试插件代码? | [04-调试运行指南](01-快速开始/04-调试运行指南.md) |
| 插件什么时候初始化?能做什么? | [01-插件生命周期](02-核心概念与原理/01-插件生命周期.md) |
| 什么是桌面组件?如何创建? | [02-桌面组件系统](02-核心概念与原理/02-桌面组件系统.md) |
| 如何添加设置页面? | [03-设置系统集成](02-核心概念与原理/03-设置系统集成.md) + [04-开发设置页面](04-实战案例/04-开发设置页面.md) |
| 如何适配暗色模式? | [04-外观与主题系统](02-核心概念与原理/04-外观与主题系统.md) |
| 插件之间如何通信? | [05-插件间通信](02-核心概念与原理/05-插件间通信.md) |
| 完整的组件开发示例? | [01-开发天气组件](04-实战案例/01-开发天气组件.md) |
| 如何排查插件不加载的问题? | [03-常见问题排查](05-调试与故障排除/03-常见问题排查.md) |
| 如何配置 GitHub Actions | [01-GitHub Actions入门](06-CI-CD与自动化/01-GitHub Actions入门.md) |
| 如何自动打包 .laapp | [03-自动打包与发布](06-CI-CD与自动化/03-自动打包与发布.md) |
| 如何发布到插件市场? | [03-发布到插件市场](07-发布与运营/03-发布到插件市场.md) |
---
## 📚 相关资源
### 官方资源
| 资源 | 位置 | 说明 |
|-----|------|------|
| **Plugin SDK 源码** | `LanMountainDesktop.PluginSdk/` | SDK 的完整源码和 XML 注释 |
| **插件模板** | `LanMountainDesktop.PluginTemplate/` | `dotnet new` 模板源码 |
| **共享契约** | `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的类型定义 |
| **架构文档** | `docs/ARCHITECTURE.md` | 宿主应用架构说明 |
| **视觉规范** | `docs/VISUAL_SPEC.md` | UI 设计规范 |
| **圆角规范** | `docs/CORNER_RADIUS_SPEC.md` | 圆角设计系统 |
| **开发指南** | `docs/DEVELOPMENT.md` | 宿主开发指南 |
### 外部资源
| 资源 | 链接 | 说明 |
|-----|------|------|
| **示例插件仓库** | `LanMountainDesktop.SamplePlugin` | 官方示例插件(独立仓库) |
| **Avalonia UI 文档** | https://docs.avaloniaui.net/ | UI 框架官方文档 |
| **FluentAvalonia** | https://github.com/amwx/FluentAvalonia | 主题控件库 |
| **.NET 文档** | https://learn.microsoft.com/dotnet/ | .NET 官方文档 |
---
## 💡 获取帮助
如果在开发过程中遇到问题:
1. **查阅本文档** - 使用上方快速索引找到相关章节
2. **查看示例代码** - 参考 `LanMountainDesktop.PluginTemplate/content/` 中的模板代码
3. **阅读 SDK 源码** - `LanMountainDesktop.PluginSdk/` 中有详细的 XML 注释
4. **搜索 Issues** - 在 GitHub 仓库搜索是否有人遇到类似问题
5. **提交 Issue** - 如果确认是 bug欢迎提交 Issue
---
## 🎯 下一步
准备好开始了吗?点击 **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** 开始你的插件开发之旅!
---
*最后更新2026年4月*

View File

@@ -0,0 +1,220 @@
# 01-开发环境准备
在开始开发阑山桌面插件之前,你需要准备好开发环境。本指南将带你完成所有必要的安装和配置。
---
## ✅ 系统要求
### 支持的操作系统
| 操作系统 | 版本要求 | 备注 |
|---------|---------|------|
| **Windows** | Windows 10 版本 1809 或更高 | 推荐开发平台 |
| **Windows** | Windows 11 | 最佳体验 |
| **Linux** | Ubuntu 20.04+ / Debian 10+ | 支持开发和运行 |
| **macOS** | macOS 12+ | 支持开发和运行 |
### 硬件要求
- **处理器**x64 或 ARM64 架构
- **内存**:至少 4GB RAM推荐 8GB
- **磁盘空间**:至少 2GB 可用空间
---
## 🛠️ 安装 .NET SDK
阑山桌面插件基于 **.NET 10** 开发,你需要安装对应版本的 SDK。
### 下载安装
1. 访问 [.NET 10 下载页面](https://dotnet.microsoft.com/download/dotnet/10.0)
2. 下载适合你操作系统的 SDK 安装包
3. 运行安装程序,按提示完成安装
### 验证安装
打开终端PowerShell、CMD 或 Bash运行以下命令
```powershell
# 检查 .NET SDK 版本
dotnet --version
```
**预期输出示例:**
```
10.0.100
```
⚠️ **如果版本低于 10.0**,请重新下载安装最新版 .NET 10 SDK。
---
## 💻 安装 IDE集成开发环境
你可以选择以下任一 IDE 进行开发:
### 选项 1Visual Studio 2022推荐 Windows 用户)
**优点:** 功能最全,调试体验最佳
1. 下载 [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
2. 安装时选择以下工作负载:
-**.NET 桌面开发**
-**Avalonia UI 开发**(可选,如需 Avalonia 设计器)
### 选项 2JetBrains Rider跨平台推荐
**优点:** 跨平台智能提示强大Avalonia 支持好
1. 下载 [Rider](https://www.jetbrains.com/rider/)
2. 安装后打开,会自动检测 .NET SDK
### 选项 3Visual Studio Code轻量级
**优点:** 免费,轻量,插件丰富
1. 下载 [VS Code](https://code.visualstudio.com/)
2. 安装以下扩展:
- **C# Dev Kit**Microsoft 官方)
- **Avalonia for VS Code**(可选)
---
## 📦 安装插件模板
阑山桌面提供了官方的 `dotnet new` 模板,帮助你快速创建插件项目。
### 安装模板
```powershell
# 安装最新版插件模板
dotnet new install LanMountainDesktop.PluginTemplate
```
**成功提示:**
```
模板名 短名称 语言 标签
------------------------------------- ---------- ---- ------------
LanMountainDesktop Plugin lmd-plugin C# LanMountainDesktop/Plugin
```
### 验证安装
```powershell
# 列出已安装的模板,查找 lmd-plugin
dotnet new list | findstr lmd
```
Linux/macOS
```bash
dotnet new list | grep lmd
```
---
## 🎮 获取宿主应用
插件需要在阑山桌面宿主中运行,你需要获取宿主应用:
### 方式 1下载 Release 版本(推荐)
1. 访问 GitHub Releases 页面
2. 下载最新版本的安装包(.exe / .deb / .dmg
3. 安装并运行阑山桌面
### 方式 2从源码构建
如果你想调试宿主或了解内部机制:
```powershell
# 克隆仓库
git clone https://github.com/your-org/LanMountainDesktop.git
cd LanMountainDesktop
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
---
## 🔍 环境验证清单
在继续之前,请确认以下检查项都已完成:
| 检查项 | 验证命令 | 预期结果 |
|-------|---------|---------|
| ✅ .NET SDK 版本 | `dotnet --version` | 10.0.xxx |
| ✅ 模板已安装 | `dotnet new list \| findstr lmd` | 显示 lmd-plugin |
| ✅ IDE 可创建项目 | 在 IDE 中新建项目 | 能看到 C# 项目模板 |
| ✅ 宿主可运行 | 双击 LanMountainDesktop.exe | 应用正常启动 |
---
## ⚠️ 常见问题
### 问题 1dotnet 命令找不到
**现象:** 运行 `dotnet` 提示不是内部或外部命令
**解决:**
1. 确认 .NET SDK 已正确安装
2. 重启终端或 IDE
3. 检查环境变量 PATH 是否包含 `C:\Program Files\dotnet\`
### 问题 2模板安装失败
**现象:** `dotnet new install` 报错或卡住
**解决:**
1. 检查网络连接(需要访问 nuget.org
2. 尝试指定版本号:
```powershell
dotnet new install LanMountainDesktop.PluginTemplate::1.0.0
```
3. 清除模板缓存后重试:
```powershell
dotnet new uninstall LanMountainDesktop.PluginTemplate
dotnet new install LanMountainDesktop.PluginTemplate
```
### 问题 3SDK 版本不匹配
**现象:** 构建时提示 SDK 版本不符合 global.json 要求
**解决:**
1. 检查项目根目录的 `global.json` 文件
2. 安装对应版本的 .NET SDK
3. 或使用以下命令使用已安装的版本:
```powershell
dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
```
---
## 🎯 下一步
环境准备完成!接下来:
👉 **[02-三分钟创建第一个插件](02-三分钟创建第一个插件.md)** - 立即开始创建你的第一个插件!
---
## 📚 参考资源
- [.NET 10 下载](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
- [JetBrains Rider](https://www.jetbrains.com/rider/)
- [VS Code](https://code.visualstudio.com/)
- [Avalonia UI 文档](https://docs.avaloniaui.net/)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,236 @@
# 02-三分钟创建第一个插件
本指南将帮助你在三分钟内创建并运行你的第一个阑山桌面插件。让我们开始吧!
---
## 🎯 目标
完成本指南后,你将:
- ✅ 创建一个可运行的插件项目
- ✅ 在宿主中成功加载插件
- ✅ 在插件列表中看到你的插件
---
## ⚡ 步骤一创建项目30秒
打开终端,运行以下命令:
```powershell
# 创建插件项目
dotnet new lmd-plugin -n MyFirstPlugin
# 进入项目目录
cd MyFirstPlugin
```
**成功标志:** 命令执行后没有报错,且生成了 `MyFirstPlugin` 文件夹。
---
## 📝 步骤二配置插件信息30秒
打开 `plugin.json` 文件,修改以下字段:
```json
{
"id": "com.yourname.myfirstplugin",
"name": "我的第一个插件",
"description": "这是一个测试插件",
"author": "你的名字",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyFirstPlugin.dll",
"sharedContracts": []
}
```
⚠️ **重要提示:**
- `id` 必须是唯一的,建议使用反向域名格式(如 `com.yourname.pluginname`
- `apiVersion` 必须与 SDK 版本匹配
- 保存文件时使用 **UTF-8** 编码
---
## 🔨 步骤三构建项目30秒
在终端中运行:
```powershell
# 构建项目
dotnet build
```
**成功标志:** 看到类似以下的输出:
```
生成成功。
0 个警告
0 个错误
```
---
## 📦 步骤四找到插件包15秒
构建完成后,插件包位于:
```
MyFirstPlugin/
└── bin/
└── Debug/
└── net10.0/
└── MyFirstPlugin.laapp <-- 这就是插件包!
```
⚠️ **什么是 .laapp 文件?**
- `.laapp` 是阑山桌面的插件包格式
- 本质上是一个 ZIP 压缩包,包含插件 DLL 和资源文件
- 不要解压,直接安装即可
---
## 🚀 步骤五安装到宿主30秒
1. **启动阑山桌面**(如果尚未运行)
2. **打开设置**
- 右键点击桌面上的阑山桌面图标
- 选择「设置」
3. **进入插件管理**
- 在设置窗口左侧选择「插件」
4. **安装本地插件**
- 点击「安装本地插件」按钮
- 选择刚才生成的 `.laapp` 文件
- 点击「打开」
5. **重启宿主**
- 安装完成后,点击「重启」按钮
- 阑山桌面将重新启动
---
## ✅ 步骤六验证安装15秒
重启后,再次打开设置 → 插件:
🎉 **成功标志:**
- 在插件列表中看到「我的第一个插件」
- 状态显示为「已启用」
- 作者显示为你设置的名字
![插件列表示意图](此处应有截图位置)
---
## 📂 生成的项目结构
你的项目现在包含以下文件:
```
MyFirstPlugin/
├── plugin.json # 插件清单文件
├── MyFirstPlugin.csproj # 项目文件
├── Plugin.cs # 插件入口类
├── README.md # 项目说明
└── Localization/ # 本地化文件夹
├── zh-CN.json # 中文资源
└── en-US.json # 英文资源
```
---
## 🔍 查看插件代码
打开 `Plugin.cs`,你会看到:
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyFirstPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 插件初始化代码
// 在这里注册组件、设置页面等
}
}
```
**关键点:**
- `[PluginEntrance]` 特性标记入口类
- 继承 `PluginBase` 基类
- `Initialize` 方法是插件的初始化入口
---
## 🎉 恭喜!
你已经成功创建并安装了第一个阑山桌面插件!
虽然这个插件目前还没有任何功能,但你已经掌握了:
- ✅ 使用模板创建项目
- ✅ 配置插件信息
- ✅ 构建插件包
- ✅ 安装到宿主
---
## 🚦 常见问题
### 问题 1构建失败提示找不到 SDK
**现象:** 错误信息包含 "SDK not found"
**解决:**
1. 确认已安装 .NET 10 SDK`dotnet --version`
2. 检查 `global.json` 中的版本要求
### 问题 2宿主提示插件安装失败
**现象:** 安装时弹出错误对话框
**排查步骤:**
1. 检查 `plugin.json` 是否为有效的 JSON 格式
2. 确认 `id` 字段唯一且合法(只能包含字母、数字、点号)
3. 确认 `apiVersion` 与 SDK 版本匹配
### 问题 3插件列表中不显示
**现象:** 安装后重启,但列表中没有
**排查步骤:**
1. 确认已点击「重启」按钮
2. 检查日志文件:`%LOCALAPPDATA%\LanMountainDesktop\logs\`
3. 确认 `.laapp` 文件完整未损坏
---
## 🎯 下一步
现在你的插件已经能运行了,接下来学习:
👉 **[03-插件项目结构详解](03-插件项目结构详解.md)** - 深入理解每个文件的作用
或者直接进入实战:
👉 **[02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)** - 创建你的第一个桌面组件!
---
## 💡 小贴士
- **快速重建**:修改代码后,只需运行 `dotnet build` 即可重新生成 `.laapp`
- **自动安装**:可以在 IDE 中配置构建后自动复制到宿主插件目录
- **日志调试**:使用 `ILogger` 记录日志,在 `%LOCALAPPDATA%\LanMountainDesktop\logs\` 查看
---
*最后更新2026年4月*

View File

@@ -0,0 +1,350 @@
# 03-插件项目结构详解
了解插件项目的每个文件和文件夹的作用,是开发高质量插件的基础。本文将详细解析插件项目的完整结构。
---
## 📂 项目结构概览
使用模板创建的插件项目结构如下:
```
MyPlugin/
├── plugin.json # 插件清单(必需)
├── MyPlugin.csproj # 项目文件(必需)
├── Plugin.cs # 入口类(必需)
├── README.md # 项目说明(推荐)
├── .gitignore # Git忽略文件可选
└── Localization/ # 本地化文件夹(可选)
├── zh-CN.json # 中文资源
└── en-US.json # 英文资源
```
---
## 📋 plugin.json - 插件清单
这是插件最重要的配置文件,定义了插件的元数据。
### 完整示例
```json
{
"id": "com.example.myplugin",
"name": "我的插件",
"description": "这是一个示例插件",
"author": "作者名称",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyPlugin.dll",
"sharedContracts": [],
"website": "https://example.com",
"icon": "icon.png",
"tags": ["工具", "实用"]
}
```
### 字段详解
| 字段 | 必需 | 说明 | 示例 |
|-----|------|------|------|
| `id` | ✅ | 唯一标识符,反向域名格式 | `com.yourname.plugin` |
| `name` | ✅ | 显示名称 | `天气插件` |
| `description` | ✅ | 简短描述 | `显示实时天气信息` |
| `author` | ✅ | 作者名称 | `张三` |
| `version` | ✅ | 版本号(语义化版本) | `1.0.0` |
| `apiVersion` | ✅ | SDK API 版本 | `4.0.1` |
| `entranceAssembly` | ✅ | 入口程序集文件名 | `MyPlugin.dll` |
| `sharedContracts` | ✅ | 共享契约类型列表 | `[]` |
| `website` | ❌ | 项目网站 | `https://github.com/...` |
| `icon` | ❌ | 图标文件名 | `icon.png` |
| `tags` | ❌ | 标签数组 | `["天气", "工具"]` |
### 重要规则
⚠️ **id 字段规则:**
- 只能包含小写字母、数字、点号(`.`
- 必须全局唯一
- 建议使用反向域名格式:`com.yourname.pluginname`
- 一经发布不可更改
⚠️ **version 字段规则:**
- 使用语义化版本格式:`主版本.次版本.修订号`
- 示例:`1.0.0``2.1.3-beta`
⚠️ **apiVersion 字段规则:**
- 必须与引用的 SDK 版本兼容
- 当前最新版本:`4.0.1`
- 不兼容时宿主将拒绝加载插件
---
## 🔧 .csproj - 项目文件
定义了项目的构建配置和依赖项。
### 完整示例
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
```
### 关键配置项
| 配置项 | 说明 | 推荐值 |
|-------|------|--------|
| `TargetFramework` | 目标框架 | `net10.0` |
| `LangVersion` | C# 语言版本 | `latest` |
| `Nullable` | 可空引用类型 | `enable` |
### SDK 引用
```xml
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
```
⚠️ **版本必须匹配:**
- SDK 版本必须与 `plugin.json` 中的 `apiVersion` 兼容
- 建议使用最新稳定版
### 资源文件配置
确保 `plugin.json` 和本地化文件被正确复制到输出目录:
```xml
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
```
---
## 🚪 Plugin.cs - 入口类
插件的入口点,负责初始化逻辑。
### 基本结构
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 在这里注册组件、设置页面、服务等
// 示例:注册桌面组件
// services.AddPluginDesktopComponent<MyWidget>(...);
// 示例:注册设置页面
// services.AddPluginSettingsSection(...);
}
}
```
### 关键特性
| 特性/类 | 说明 |
|--------|------|
| `[PluginEntrance]` | 标记插件入口类,必须有且仅有一个 |
| `PluginBase` | 插件基类,提供基础功能和日志访问 |
| `Initialize` | 初始化方法,宿主启动时调用 |
### Initialize 方法参数
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
```
| 参数 | 类型 | 说明 |
|-----|------|------|
| `context` | `HostBuilderContext` | 宿主构建上下文,可访问配置 |
| `services` | `IServiceCollection` | 依赖注入服务集合,用于注册组件和服务 |
---
## 🌍 Localization - 本地化文件夹
存放多语言资源文件,支持插件的国际化。
### 文件夹结构
```
Localization/
├── zh-CN.json # 简体中文
├── zh-TW.json # 繁体中文
├── en-US.json # 英文(美国)
├── ja-JP.json # 日文
└── ko-KR.json # 韩文
```
### 资源文件格式
```json
{
"PluginName": "我的插件",
"Settings": {
"Title": "设置",
"RefreshInterval": "刷新间隔"
},
"Messages": {
"Loading": "加载中...",
"Error": "出错了:{0}"
}
}
```
### 在代码中使用
```csharp
// 获取本地化字符串
var localizer = serviceProvider.GetRequiredService<IStringLocalizer<MyPlugin>>();
var pluginName = localizer["PluginName"];
var message = localizer["Messages.Error", errorDetails];
```
### 支持的语言代码
| 语言 | 代码 |
|-----|------|
| 简体中文 | `zh-CN` |
| 繁体中文 | `zh-TW` |
| 英文 | `en-US` |
| 日文 | `ja-JP` |
| 韩文 | `ko-KR` |
---
## 📦 构建输出结构
运行 `dotnet build` 后,生成的输出结构:
```
bin/Debug/net10.0/
├── MyPlugin.dll # 插件程序集
├── MyPlugin.pdb # 调试符号
├── plugin.json # 插件清单(复制)
├── Localization/ # 本地化文件夹(复制)
│ └── zh-CN.json
├── MyPlugin.laapp # 插件包(由 SDK 自动生成)
└── ...(依赖项 DLL
```
### .laapp 包结构
`.laapp` 文件本质是一个 ZIP 压缩包,包含:
```
MyPlugin.laapp
├── plugin.json # 清单文件
├── MyPlugin.dll # 主程序集
├── Localization/ # 本地化资源
└── ...(其他依赖 DLL
```
---
## 🔗 与其他 .NET 项目的区别
| 特性 | 普通 .NET 应用 | 阑山桌面插件 |
|-----|---------------|-------------|
| 入口点 | `Program.cs``Main` | `Plugin.cs``Initialize` |
| 运行方式 | 独立运行 | 由宿主加载运行 |
| 依赖注入 | 自行配置 | 使用宿主提供的 `IServiceCollection` |
| 输出格式 | `.exe``.dll` | `.laapp` 包 |
| 资源访问 | 直接访问 | 通过 SDK API 访问宿主资源 |
| 热重载 | 支持 | 不支持(需重启宿主) |
---
## 🎯 最佳实践
### 项目组织建议
```
MyPlugin/
├── plugin.json
├── MyPlugin.csproj
├── Plugin.cs # 入口类(保持简洁)
├── README.md
├── .gitignore
├── Localization/ # 本地化资源
├── Services/ # 服务类文件夹
│ ├── WeatherService.cs
│ └── DataService.cs
├── Views/ # 视图文件夹
│ ├── WeatherWidget.axaml
│ ├── WeatherWidget.axaml.cs
│ └── SettingsPage.axaml
└── ViewModels/ # 视图模型文件夹
├── WeatherViewModel.cs
└── SettingsViewModel.cs
```
### 文件命名规范
| 类型 | 命名约定 | 示例 |
|-----|---------|------|
| 入口类 | `Plugin` | `Plugin.cs` |
| 组件视图 | `{Name}Widget` | `WeatherWidget.axaml` |
| 设置页面 | `{Name}SettingsPage` | `WeatherSettingsPage.axaml` |
| 服务类 | `{Name}Service` | `WeatherService.cs` |
| 视图模型 | `{Name}ViewModel` | `WeatherViewModel.cs` |
---
## 📚 参考资源
- [Plugin SDK 源码](../../LanMountainDesktop.PluginSdk/)
- [插件模板](../../LanMountainDesktop.PluginTemplate/content/)
- [02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)
- [03-设置系统集成](../02-核心概念与原理/03-设置系统集成.md)
---
## 🎯 下一步
理解了项目结构后,接下来学习:
👉 **[04-调试运行指南](04-调试运行指南.md)** - 掌握调试技巧
或者深入了解核心概念:
👉 **[01-插件生命周期](../02-核心概念与原理/01-插件生命周期.md)** - 理解插件运行机制
---
*最后更新2026年4月*

View File

@@ -0,0 +1,380 @@
# 04-调试运行指南
掌握插件调试技巧,能大幅提升开发效率。本文介绍阑山桌面插件的各种调试方法和常见问题排查。
---
## 🔄 调试方式概述
阑山桌面插件有两种主要调试方式:
| 方式 | 适用场景 | 优点 | 缺点 |
|-----|---------|------|------|
| **附加到进程** | 日常开发调试 | 不改变项目结构 | 每次需手动附加 |
| **独立调试** | 深度调试、单元测试 | 启动即调试 | 配置较复杂 |
---
## 🎯 方式一:附加到进程(推荐)
这是日常开发中最常用的调试方式。
### 步骤
1. **启动阑山桌面**
- 正常启动宿主应用(非调试模式)
- 确保你的插件已安装
2. **在 IDE 中打开插件项目**
- 使用 Visual Studio / Rider / VS Code 打开项目
3. **设置断点**
- 在你想要调试的代码行左侧点击,设置断点
- 常见断点位置:
- `Plugin.Initialize()` - 插件初始化
- 组件构造函数
- 设置页面加载方法
4. **附加到进程**
**Visual Studio**
- 菜单:`调试``附加到进程`
- 或快捷键:`Ctrl+Alt+P`
- 在列表中找到 `LanMountainDesktop.exe`
- 点击`附加`
**Rider**
- 菜单:`Run``Attach to Process`
- 或快捷键:`Ctrl+Alt+F5`
- 选择 `LanMountainDesktop.exe`
**VS Code**
-`Ctrl+Shift+D` 打开调试面板
- 点击`创建 launch.json 文件`
- 选择 `.NET Core Attach`
- 选择 `LanMountainDesktop` 进程
5. **触发调试**
- 在阑山桌面中操作,触发插件代码
- 例如:添加组件、打开设置页面等
- 程序会在断点处暂停
### 附加配置VS Code
创建 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "附加到阑山桌面",
"type": "coreclr",
"request": "attach",
"processName": "LanMountainDesktop"
}
]
}
```
---
## 🔧 方式二:独立调试
适用于深度调试或单元测试。
### 配置步骤
1. **修改 .csproj 临时引用宿主**
```xml
<ItemGroup>
<!-- 临时添加,仅用于调试 -->
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
</ItemGroup>
```
2. **创建调试启动配置**
**Visual Studio**
- 右键项目 → `属性` → `调试`
- 启动外部程序:选择 `LanMountainDesktop.exe`
- 工作目录:设为宿主输出目录
**VS Code launch.json**
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "启动阑山桌面(调试)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe",
"args": [],
"cwd": "${workspaceFolder}/../LanMountainDesktop/bin/Debug/net10.0",
"stopAtEntry": false
}
]
}
```
3. **启动调试**
- 按 `F5` 启动
- 宿主会以调试模式启动
- 插件代码中的断点会直接命中
⚠️ **注意:** 发布插件前务必移除临时引用!
---
## 📝 日志调试
当断点调试不方便时,日志是最有效的调试手段。
### 使用 ILogger
```csharp
using Microsoft.Extensions.Logging;
public class MyService
{
private readonly ILogger<MyService> _logger;
public MyService(ILogger<MyService> logger)
{
_logger = logger;
}
public void DoWork()
{
_logger.LogInformation("开始执行任务");
try
{
// 业务逻辑
_logger.LogDebug("处理数据: {Data}", data);
}
catch (Exception ex)
{
_logger.LogError(ex, "任务执行失败");
}
_logger.LogInformation("任务完成");
}
}
```
### 日志级别
| 级别 | 使用场景 |
|-----|---------|
| `LogTrace` | 最详细的跟踪信息 |
| `LogDebug` | 调试信息 |
| `LogInformation` | 一般信息 |
| `LogWarning` | 警告信息 |
| `LogError` | 错误信息 |
| `LogCritical` | 严重错误 |
### 查看日志文件
日志文件位置:
```
Windows: %LOCALAPPDATA%\LanMountainDesktop\logs\
Linux: ~/.local/share/LanMountainDesktop/logs/
macOS: ~/Library/Application Support/LanMountainDesktop/logs/
```
日志文件命名格式:
```
log-20240413.txt
log-20240413_001.txt
```
### 实时查看日志
**Windows PowerShell**
```powershell
Get-Content "$env:LOCALAPPDATA\LanMountainDesktop\logs\log-$(Get-Date -Format 'yyyyMMdd').txt" -Wait
```
**Linux/macOS**
```bash
tail -f ~/.local/share/LanMountainDesktop/logs/log-$(date +%Y%m%d).txt
```
---
## 🚫 热重载限制
⚠️ **重要:** 阑山桌面插件**不支持**热重载Hot Reload
### 原因
插件运行在独立的 `AssemblyLoadContext` 中,.NET 不支持卸载已加载的程序集。因此:
- 修改代码后必须重新构建
- 必须重启宿主才能加载新版本
- 无法使用 `dotnet watch`
### 高效开发流程
```
修改代码 → dotnet build → 重启宿主 → 测试
```
**加速技巧:**
1. **创建批处理脚本**`rebuild-and-run.ps1`
```powershell
dotnet build
Stop-Process -Name "LanMountainDesktop" -ErrorAction SilentlyContinue
Start-Process "C:\Path\To\LanMountainDesktop.exe"
```
2. **使用 Rider 的外部工具**
- 配置构建后自动复制 `.laapp` 到插件目录
---
## 🐛 常见问题排查
### 问题 1断点不命中
**可能原因:**
- 插件未重新构建
- PDB 符号文件未生成
- 附加到了错误的进程
**解决步骤:**
1. 确认已重新构建:`dotnet build`
2. 检查输出目录是否有 `.pdb` 文件
3. 确认附加的是 `LanMountainDesktop.exe`(不是 `LanMountainDesktop.dll`
4. 尝试清理重建:
```powershell
dotnet clean
dotnet build
```
### 问题 2插件不加载
**排查步骤:**
1. **检查日志**
```powershell
Get-Content "$env:LOCALAPPDATA\LanMountainDesktop\logs\log-$(Get-Date -Format 'yyyyMMdd').txt" | Select-String "MyPlugin"
```
2. **验证 plugin.json**
- JSON 格式是否有效
- `id` 是否合法(只含小写字母、数字、点号)
- `apiVersion` 是否与 SDK 版本匹配
3. **检查 .laapp 包**
- 用压缩软件打开,确认文件完整
- 确认 `plugin.json` 和 DLL 存在
### 问题 3依赖项找不到
**现象:** `FileNotFoundException` 或 `Could not load file or assembly`
**解决:**
1. 确保所有依赖项都复制到输出目录
2. 在 `.csproj` 中添加:
```xml
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
```
### 问题 4调试时宿主卡顿
**原因:** 断点暂停导致 UI 线程阻塞
**解决:**
- 使用 `Debugger.Break()` 代替断点
- 或使用日志代替断点调试
---
## 💡 调试技巧
### 1. 条件断点
当需要在特定条件下暂停时使用:
**Visual Studio**
- 右键断点 → `条件`
- 输入条件表达式,如:`count > 10`
### 2. 日志点Tracepoint
不暂停程序,只输出日志:
**Visual Studio**
- 右键断点 → `操作`
- 勾选 `将消息输出到输出窗口`
- 输入消息模板:`变量值: {variableName}`
### 3. 异常设置
自动在抛出异常时中断:
**Visual Studio**
- `调试` → `窗口` → `异常设置`
- 勾选 `Common Language Runtime Exceptions`
### 4. 立即窗口
在调试时执行代码:
**Visual Studio**
- 快捷键:`Ctrl+Alt+I`
- 可查看变量值、调用方法
---
## 📊 性能调试
### 使用 Diagnostic Tools
**Visual Studio**
- 调试时自动显示 CPU 和内存使用情况
- `调试` → `窗口` → `诊断工具`
### 内存泄漏排查
```csharp
// 在可疑位置添加诊断代码
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var memory = GC.GetTotalMemory(true);
Debug.WriteLine($"内存使用: {memory / 1024 / 1024} MB");
```
---
## 🎯 下一步
掌握了调试技巧后,接下来学习核心概念:
👉 **[01-插件生命周期](../02-核心概念与原理/01-插件生命周期.md)** - 理解插件运行机制
或者查看实战案例:
👉 **[01-开发天气组件](../04-实战案例/01-开发天气组件.md)** - 完整开发流程
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md)
- [Visual Studio 调试文档](https://docs.microsoft.com/visualstudio/debugger/)
- [Rider 调试文档](https://www.jetbrains.com/help/rider/Debugging.html)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,398 @@
# 01-插件生命周期
理解插件的生命周期,是开发稳定可靠插件的基础。本文详细讲解插件从加载到卸载的完整过程。
---
## 🔄 生命周期概览
```
┌─────────────────────────────────────────────────────────────┐
│ 阑山桌面启动 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 1. 发现插件 │
│ - 扫描插件目录 │
│ - 解析 plugin.json │
│ - 验证 API 版本兼容性 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. 加载插件 │
│ - 创建 AssemblyLoadContext │
│ - 加载插件 DLL │
│ - 查找入口类(带 [PluginEntrance] 特性) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. 初始化Initialize
│ - 调用 Plugin.Initialize() │
│ - 注册组件、设置页面、服务 │
│ - ⚠️ 此时 UI 尚未完全就绪 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. 运行中 │
│ - 组件被添加到桌面 │
│ - 用户与组件交互 │
│ - 设置页面被打开 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. 停用/卸载 │
│ - 用户禁用插件 │
│ - 或关闭阑山桌面 │
│ - 释放资源(当前版本无显式卸载回调) │
└─────────────────────────────────────────────────────────────┘
```
---
## 📋 各阶段详解
### 阶段 1发现插件
**时机:** 阑山桌面启动时
**过程:**
1. 扫描 `%LOCALAPPDATA%\LanMountainDesktop\plugins\` 目录
2. 读取每个 `.laapp` 包中的 `plugin.json`
3. 验证 `apiVersion` 是否与宿主兼容
4. 检查 `id` 是否唯一
**可能失败的原因:**
- `plugin.json` 格式错误
- `apiVersion` 不兼容
- `id` 与其他插件冲突
---
### 阶段 2加载插件
**时机:** 发现成功后
**过程:**
1. 创建独立的 `AssemblyLoadContext`
2. 加载插件 DLL 及其依赖项
3. 查找带有 `[PluginEntrance]` 特性的类
4. 实例化插件入口类
**代码示例:**
```csharp
[PluginEntrance] // ← 这个特性标记入口类
public sealed class Plugin : PluginBase
{
// 插件实例在此阶段被创建
}
```
⚠️ **重要:** 此阶段**不要**执行耗时操作,只应进行简单的字段初始化。
---
### 阶段 3初始化Initialize
**时机:** 插件加载完成后
**这是插件开发中最重要的阶段!**
#### 方法签名
```csharp
public override void Initialize(
HostBuilderContext context, // 宿主构建上下文
IServiceCollection services) // 服务注册集合
```
#### 可执行的操作
**可以做的:**
- 注册桌面组件
- 注册设置页面
- 注册服务到依赖注入容器
- 读取配置
- 初始化资源
**不应该做的:**
- 访问 UI 元素UI 尚未就绪)
- 执行耗时阻塞操作
- 创建窗口或对话框
#### 典型初始化代码
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 1. 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
// 2. 注册桌面组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Weather",
DisplayName = "天气",
IconKey = "Weather",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3
});
// 3. 注册设置页面
services.AddPluginSettingsSection(
"myplugin-settings",
"天气设置",
section => section
.AddToggle("auto_refresh", "自动刷新", defaultValue: true)
.AddNumber("interval", "刷新间隔(分钟)", defaultValue: 30),
iconKey: "Settings");
}
```
---
### 阶段 4运行中
**时机:** 初始化完成后,直到插件被禁用或宿主关闭
**特点:**
- 组件可以被添加到桌面
- 用户可以与组件交互
- 设置页面可以被打开
- 定时器可以运行
#### 组件生命周期
```
用户添加组件
┌─────────────────┐
│ 创建组件实例 │ ← 调用构造函数
│ (Dependency │ 注入 IServiceProvider
│ Injection) │
└────────┬────────┘
┌─────────────────┐
│ 组件初始化 │ ← 可在此时加载数据
│ (Loaded事件) │
└────────┬────────┘
┌─────────────────┐
│ 渲染显示 │ ← 用户看到组件
└────────┬────────┘
┌────┴────┐
▼ ▼
用户交互 定时更新
│ │
└────┬────┘
┌─────────────────┐
│ 组件移除 │ ← 用户删除组件
│ (Unloaded事件) │ 或关闭宿主
└─────────────────┘
```
---
### 阶段 5停用/卸载
**时机:**
- 用户在设置中禁用插件
- 卸载插件
- 关闭阑山桌面
**当前限制:**
- SDK v4 暂无显式的卸载回调方法
- 资源释放依赖 .NET 垃圾回收
- 建议:
- 使用 `IDisposable` 模式管理资源
- 在组件卸载事件中清理资源
---
## ⏱️ 启动时序图
```
阑山桌面 插件系统 你的插件
│ │ │
│── 启动 ───────►│ │
│ │ │
│ │── 发现插件 ───►│
│ │ │ (读取 plugin.json)
│ │◄───────────────│
│ │ │
│ │── 加载 DLL ───►│
│ │ │ (AssemblyLoadContext)
│ │◄───────────────│
│ │ │
│ │── 创建实例 ───►│
│ │ │ (调用构造函数)
│ │◄───────────────│
│ │ │
│ │── Initialize ─►│
│ │ │ (注册组件/服务)
│ │◄───────────────│
│ │ │
│◄───────────────│ │
│ │ │
│── UI就绪 ─────►│ │
│ │ │
│ │── 用户添加组件 ─►│
│ │ │ (创建组件实例)
```
---
## 💡 最佳实践
### 1. Initialize 方法保持轻量
```csharp
// ✅ 好的做法:快速注册
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<IDataService, DataService>();
services.AddPluginDesktopComponent<MyWidget>(options);
}
// ❌ 避免:耗时操作
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 不要这样做!
var data = FetchDataFromInternet().Result; // 阻塞!
services.AddSingleton(data);
}
```
### 2. 延迟加载数据
```csharp
public class MyWidget : Border
{
private readonly IDataService _dataService;
public MyWidget(PluginDesktopComponentContext context)
{
_dataService = context.ServiceProvider.GetRequiredService<IDataService>();
// 在 Loaded 事件中加载数据,而不是构造函数
Loaded += async (_, _) =>
{
await LoadDataAsync();
};
}
private async Task LoadDataAsync()
{
var data = await _dataService.GetDataAsync();
// 更新 UI
}
}
```
### 3. 正确处理资源释放
```csharp
public class MyWidget : Border, IDisposable
{
private readonly Timer _timer;
private bool _disposed;
public MyWidget()
{
_timer = new Timer(OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
// 在卸载时释放资源
Unloaded += (_, _) => Dispose();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_timer?.Dispose();
}
}
```
### 4. 避免循环依赖
```csharp
// ❌ 避免:服务之间相互依赖
public class ServiceA
{
public ServiceA(ServiceB b) { } // 循环依赖风险
}
public class ServiceB
{
public ServiceB(ServiceA a) { }
}
// ✅ 好的做法:使用接口解耦
public class ServiceA
{
public ServiceA(IServiceB b) { }
}
```
---
## 🐛 常见问题
### 问题 1Initialize 中访问 UI 报错
**现象:** `InvalidOperationException` 或空引用
**原因:** Initialize 在 UI 就绪前调用
**解决:** 延迟到组件创建后再访问 UI
### 问题 2服务注册顺序问题
**现象:** 依赖注入找不到服务
**原因:** 服务注册顺序不正确
**解决:** 先注册服务,再注册依赖这些服务的组件
### 问题 3插件加载慢
**现象:** 宿主启动变慢
**原因:** Initialize 中执行耗时操作
**解决:** 将耗时操作移到后台线程或延迟执行
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [IPlugin 接口](../../LanMountainDesktop.PluginSdk/IPlugin.cs)
- [02-桌面组件系统](02-桌面组件系统.md)
- [03-设置系统集成](03-设置系统集成.md)
---
## 🎯 下一步
理解生命周期后,学习如何创建桌面组件:
👉 **[02-桌面组件系统](02-桌面组件系统.md)** - 创建可视化组件
---
*最后更新2026年4月*

View File

@@ -0,0 +1,393 @@
# 02-桌面组件系统
桌面组件Desktop Component是阑山桌面插件的核心功能。本文详细讲解组件系统的工作原理和开发方法。
---
## 🎯 什么是桌面组件
桌面组件是显示在阑山桌面上的可视化元素,用户可以自由:
- 添加/删除组件
- 拖动调整位置
- 调整大小
- 配置属性
**常见组件示例:**
- 时钟组件 - 显示当前时间
- 天气组件 - 显示天气信息
- 日历组件 - 显示日期和日程
- 系统监控 - 显示 CPU/内存使用率
---
## 📐 网格系统
阑山桌面使用网格系统管理组件布局。
### 网格概念
```
┌─────────────────────────────────────────┐
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ 2x2│ │ 2x2│ │ 2x2│ │ 2x2│ │
│ │ 格 │ │ 格 │ │ 格 │ │ 格 │ │
│ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────────┐ ┌────┐ │
│ │ 2x2│ │ 4x2 │ │ 2x2│ │
│ │ 格 │ │ 格 │ │ 格 │ │
│ └────┘ └────────┘ └────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ 4x3 │ │ 4x3 │ │
│ │ 格 │ │ 格 │ │
│ └────────┘ └────────┘ │
└─────────────────────────────────────────┘
每格大小:约 60-80 像素(根据 DPI 自动调整)
```
### 组件尺寸
组件尺寸以**格数**为单位:
| 属性 | 说明 | 示例 |
|-----|------|------|
| `MinWidthCells` | 最小宽度(格数) | 4 = 4格宽 |
| `MinHeightCells` | 最小高度(格数) | 3 = 3格高 |
**常见尺寸参考:**
| 组件类型 | 推荐尺寸 | 实际像素(约) |
|---------|---------|--------------|
| 小图标 | 2x2 | 120x120 |
| 天气卡片 | 4x3 | 240x180 |
| 时钟 | 4x4 | 240x240 |
| 日历 | 6x4 | 360x240 |
| 宽面板 | 8x3 | 480x180 |
---
## 🏗️ 创建组件
### 步骤 1创建组件类
组件是继承自 Avalonia 控件的类:
```csharp
using Avalonia.Controls;
using LanMountainDesktop.PluginSdk;
namespace MyPlugin;
public class WeatherWidget : Border // 继承自 Border 或其他控件
{
public WeatherWidget(PluginDesktopComponentContext context)
{
// 组件初始化
InitializeComponent(context);
}
private void InitializeComponent(PluginDesktopComponentContext context)
{
// 设置背景
Background = new SolidColorBrush(Colors.Transparent);
// 设置圆角(使用宿主主题)
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 创建内容
var textBlock = new TextBlock
{
Text = "天气组件",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
Child = textBlock;
}
}
```
### 步骤 2注册组件
`Plugin.Initialize` 中注册:
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Weather", // 唯一标识
DisplayName = "天气", // 显示名称
IconKey = "Weather", // 图标Fluent 图标名)
Category = "工具", // 分类
MinWidthCells = 4, // 最小宽度(格)
MinHeightCells = 3, // 最小高度(格)
CornerRadiusPreset = PluginCornerRadiusPreset.Component // 圆角预设
});
}
```
### PluginDesktopComponentOptions 详解
| 属性 | 必需 | 说明 | 示例 |
|-----|------|------|------|
| `ComponentId` | ✅ | 唯一标识符 | `"MyPlugin.Weather"` |
| `DisplayName` | ✅ | 显示名称 | `"天气"` |
| `IconKey` | ✅ | 图标键名 | `"Weather"``"Clock"` |
| `Category` | ✅ | 分类 | `"工具"``"信息"` |
| `MinWidthCells` | ✅ | 最小宽度(格) | `4` |
| `MinHeightCells` | ✅ | 最小高度(格) | `3` |
| `CornerRadiusPreset` | ❌ | 圆角预设 | `PluginCornerRadiusPreset.Component` |
| `ResizeMode` | ❌ | 调整大小模式 | `PluginDesktopComponentResizeMode.Free` |
### 常用 Fluent 图标
| 图标键名 | 用途 |
|---------|------|
| `Weather` | 天气相关 |
| `Clock` | 时钟、时间 |
| `Calendar` | 日历、日期 |
| `Settings` | 设置 |
| `Home` | 主页 |
| `Search` | 搜索 |
| `Star` | 收藏 |
| `Heart` | 喜欢 |
| `Info` | 信息 |
| `Warning` | 警告 |
完整图标列表:[Fluent UI System Icons](https://github.com/microsoft/fluentui-system-icons)
---
## 🎨 组件外观
### 圆角设置
插件必须使用宿主提供的圆角系统,以保持视觉一致性:
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 获取组件标准圆角
var cornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
CornerRadius = cornerRadius;
}
```
**可用的圆角预设:**
| 预设 | 用途 |
|-----|------|
| `Micro` | 微小元素 |
| `Xs` | 小元素 |
| `Sm` | 小卡片 |
| `Md` | 普通按钮/卡片 |
| `Lg` | 大面板 |
| `Xl` | 强调容器 |
| `Component` | **桌面组件标准** |
| `Default` | 自适应 |
### 背景与透明
```csharp
// 透明背景(推荐,让宿主壁纸透出)
Background = new SolidColorBrush(Colors.Transparent);
// 毛玻璃效果
Background = new SolidColorBrush(Color.Parse("#40FFFFFF"));
// 纯色背景
Background = new SolidColorBrush(Color.Parse("#FF2D2D2D"));
```
### 响应主题变化
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 订阅主题变化
context.Appearance.AppearanceChanged += (_, _) =>
{
UpdateAppearance();
};
UpdateAppearance();
}
private void UpdateAppearance()
{
// 根据当前主题更新颜色
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
Foreground = new SolidColorBrush(isDark ? Colors.White : Colors.Black);
}
```
---
## 📏 尺寸与布局
### 获取实际尺寸
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 订阅尺寸变化
SizeChanged += OnSizeChanged;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
// 获取当前实际尺寸(像素)
var width = Bounds.Width;
var height = Bounds.Height;
// 根据尺寸调整内容
if (width < 200)
{
// 小尺寸模式
ShowCompactView();
}
else
{
// 完整模式
ShowFullView();
}
}
```
### 自适应布局
```csharp
private void UpdateLayout()
{
var width = Bounds.Width;
var height = Bounds.Height;
// 根据宽高比调整布局
if (width > height * 2)
{
// 宽屏模式 - 水平排列
_layout.Orientation = Orientation.Horizontal;
}
else
{
// 正常模式 - 垂直排列
_layout.Orientation = Orientation.Vertical;
}
}
```
---
## 🔄 组件生命周期事件
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 组件加载完成(此时已添加到视觉树)
Loaded += OnLoaded;
// 组件卸载(用户删除或关闭宿主)
Unloaded += OnUnloaded;
// 尺寸变化
SizeChanged += OnSizeChanged;
}
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
// 加载数据
await LoadDataAsync();
// 启动定时器
_timer = new Timer(OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
}
private void OnUnloaded(object? sender, RoutedEventArgs e)
{
// 清理资源
_timer?.Dispose();
_httpClient?.Dispose();
}
```
---
## 💾 组件设置持久化
组件可以保存自己的设置:
```csharp
public class WeatherWidget : Border
{
private readonly IComponentSettingsAccessor _settings;
public WeatherWidget(PluginDesktopComponentContext context)
{
// 获取设置访问器
_settings = context.Settings;
// 读取设置
var city = _settings.GetValue<string>("city", defaultValue: "北京");
var autoRefresh = _settings.GetValue<bool>("auto_refresh", defaultValue: true);
// 保存设置
_settings.SetValue("city", "上海");
}
}
```
---
## 🐛 常见问题
### 问题 1组件不显示在库中
**排查:**
1. 确认已调用 `AddPluginDesktopComponent`
2. 检查 `ComponentId` 是否唯一
3. 确认组件类是 `public`
### 问题 2组件显示异常
**排查:**
1. 检查构造函数参数是否正确(需要 `PluginDesktopComponentContext`
2. 确认没有抛出未处理异常
3. 查看日志文件
### 问题 3圆角不生效
**原因:** 插件无法访问宿主 XAML 资源
**解决:** 使用代码设置圆角(见上文)
### 问题 4尺寸不正确
**排查:**
1. 检查 `MinWidthCells``MinHeightCells` 设置
2. 确认内容没有强制尺寸
---
## 📚 参考资源
- [PluginDesktopComponentOptions 源码](../../LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs)
- [04-外观与主题系统](04-外观与主题系统.md)
- [01-开发天气组件](../04-实战案例/01-开发天气组件.md)
---
## 🎯 下一步
学习如何添加设置页面:
👉 **[03-设置系统集成](03-设置系统集成.md)** - 让用户配置你的组件
---
*最后更新2026年4月*

View File

@@ -0,0 +1,353 @@
# 03-设置系统集成
设置系统允许插件在阑山桌面的设置窗口中添加自己的配置页面,让用户可以自定义插件行为。
---
## 🎯 设置系统概述
阑山桌面提供两种设置页面模式:
| 模式 | 适用场景 | 复杂度 | 灵活性 |
|-----|---------|--------|--------|
| **声明式设置** | 简单的键值配置 | 低 | 中 |
| **自定义设置页** | 复杂交互、自定义控件 | 中 | 高 |
---
## 📝 声明式设置
通过链式 API 声明配置项,宿主自动生成设置页面。
### 基本用法
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginSettingsSection(
sectionId: "myplugin-settings", // 设置节唯一标识
displayName: "我的插件设置", // 显示名称
configure: section => // 配置设置项
{
section
.AddToggle("enabled", "启用插件", defaultValue: true)
.AddText("api_key", "API密钥", defaultValue: "")
.AddNumber("interval", "刷新间隔(秒)", defaultValue: 60, minimum: 10, maximum: 3600)
.AddSelect("theme", "主题", new[]
{
new SettingsOptionChoice("light", "浅色"),
new SettingsOptionChoice("dark", "深色"),
new SettingsOptionChoice("auto", "跟随系统")
}, defaultValue: "auto");
},
iconKey: "Settings"); // 图标
}
```
### 支持的设置类型
| 方法 | 类型 | 用途 | 示例 |
|-----|------|------|------|
| `AddToggle` | 布尔 | 开关选项 | 启用/禁用 |
| `AddText` | 字符串 | 文本输入 | API密钥、用户名 |
| `AddNumber` | 数值 | 数字输入 | 刷新间隔、数量 |
| `AddSelect` | 枚举 | 下拉选择 | 主题、语言 |
| `AddPath` | 路径 | 文件/文件夹选择 | 保存路径 |
| `AddList` | 列表 | 字符串列表 | 服务器地址列表 |
### 各类型详解
#### Toggle开关
```csharp
.AddToggle(
key: "auto_update", // 设置键
displayName: "自动更新", // 显示名称
defaultValue: true, // 默认值
description: "启动时检查更新" // 可选描述
)
```
#### Text文本
```csharp
.AddText(
key: "api_key",
displayName: "API密钥",
defaultValue: "",
placeholder: "请输入API密钥", // 占位符
isPassword: true // 密码输入(掩码显示)
)
```
#### Number数值
```csharp
.AddNumber(
key: "refresh_interval",
displayName: "刷新间隔",
defaultValue: 60,
minimum: 10, // 最小值
maximum: 3600, // 最大值
increment: 10 // 步进值
)
```
#### Select选择
```csharp
.AddSelect(
key: "display_mode",
displayName: "显示模式",
choices: new[]
{
new SettingsOptionChoice("compact", "紧凑"),
new SettingsOptionChoice("normal", "标准"),
new SettingsOptionChoice("detailed", "详细")
},
defaultValue: "normal"
)
```
#### Path路径
```csharp
.AddPath(
key: "save_location",
displayName: "保存位置",
defaultValue: "",
pathType: SettingsPathType.Folder, // Folder 或 File
dialogTitle: "选择保存文件夹"
)
```
---
## 🎨 自定义设置页
当声明式设置无法满足需求时,可以创建自定义设置页面。
### 步骤 1创建设置页类
```csharp
using LanMountainDesktop.PluginSdk;
using FluentAvalonia.UI.Controls;
namespace MyPlugin;
public class MySettingsPage : SettingsPageBase
{
private readonly IPluginSettingsService _settingsService;
public MySettingsPage(IPluginSettingsService settingsService)
{
_settingsService = settingsService;
InitializeComponent();
}
private void InitializeComponent()
{
// 创建页面内容
var panel = new StackPanel { Spacing = 16 };
// 添加自定义控件
var expander = new SettingsExpander
{
Header = "高级设置",
Description = "配置插件的高级选项"
};
var toggle = new ToggleSwitch
{
Content = "启用实验性功能"
};
expander.Items.Add(toggle);
panel.Children.Add(expander);
// 添加颜色选择器示例
var colorPicker = new ColorPicker
{
Header = "主题颜色"
};
panel.Children.Add(colorPicker);
Content = panel;
}
}
```
### 步骤 2注册自定义设置页
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginSettingsSection<MySettingsPage>(
sectionId: "myplugin-advanced",
displayName: "高级设置",
iconKey: "Settings");
}
```
### 混合模式
可以同时使用声明式设置和自定义视图:
```csharp
services.AddPluginSettingsSection(
sectionId: "myplugin-settings",
displayName: "插件设置",
configure: section => section
.SetCustomView<MyCustomSettingsPage>() // 设置自定义视图
.AddToggle("enabled", "启用") // 同时声明设置项
.AddText("api_key", "API密钥"),
iconKey: "Settings");
```
---
## 💾 读取和保存设置
### 在服务中读取设置
```csharp
public class WeatherService
{
private readonly IPluginSettingsService _settings;
public WeatherService(IPluginSettingsService settings)
{
_settings = settings;
// 读取设置
var apiKey = _settings.GetValue<string>("api_key", "");
var autoRefresh = _settings.GetValue<bool>("auto_update", true);
var interval = _settings.GetValue<int>("refresh_interval", 60);
// 监听设置变化
_settings.SettingsChanged += (sender, e) =>
{
if (e.Key == "refresh_interval")
{
UpdateTimerInterval(e.NewValue);
}
};
}
}
```
### 在组件中读取设置
```csharp
public class WeatherWidget : Border
{
public WeatherWidget(PluginDesktopComponentContext context)
{
// 通过 context 获取设置
var settings = context.Settings;
var city = settings.GetValue<string>("city", "北京");
var unit = settings.GetValue<string>("temperature_unit", "celsius");
// 监听设置变化
context.Settings.SettingsChanged += (_, e) =>
{
if (e.Key == "city")
{
RefreshWeather(e.NewValue);
}
};
}
}
```
### 保存设置
```csharp
// 在设置页面中保存
private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
_settingsService.SetValue("api_key", ApiKeyTextBox.Text);
_settingsService.SetValue("auto_update", AutoUpdateToggle.IsChecked ?? false);
// 设置会自动持久化,无需手动保存文件
}
```
---
## 🔔 设置变更通知
### 订阅变更事件
```csharp
public class MyService
{
public MyService(IPluginSettingsService settings)
{
settings.SettingsChanged += OnSettingsChanged;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
Console.WriteLine($"设置变更: {e.Key}");
Console.WriteLine($"旧值: {e.OldValue}");
Console.WriteLine($"新值: {e.NewValue}");
// 根据变更的键执行相应操作
switch (e.Key)
{
case "refresh_interval":
UpdateRefreshTimer((int)e.NewValue);
break;
case "theme":
ApplyTheme((string)e.NewValue);
break;
}
}
}
```
---
## 🐛 常见问题
### 问题 1设置不保存
**排查:**
1. 确认 `sectionId` 唯一且合法
2. 检查设置键名是否正确
3. 查看日志是否有权限错误
### 问题 2设置页面不显示
**排查:**
1. 确认已调用 `AddPluginSettingsSection`
2. 检查 `sectionId` 是否唯一
3. 确认设置页类是 `public`
### 问题 3设置变更通知不触发
**原因:** 可能订阅的是不同实例
**解决:** 确保使用注入的 `IPluginSettingsService`
---
## 📚 参考资源
- [IPluginSettingsService 源码](../../LanMountainDesktop.PluginSdk/IPluginSettingsService.cs)
- [SettingsPageBase 源码](../../LanMountainDesktop.PluginSdk/SettingsPageBase.cs)
- [04-开发设置页面](../04-实战案例/04-开发设置页面.md)
---
## 🎯 下一步
学习外观系统:
👉 **[04-外观与主题系统](04-外观与主题系统.md)** - 适配宿主主题
---
*最后更新2026年4月*

View File

@@ -0,0 +1,308 @@
# 04-外观与主题系统
阑山桌面支持暗色/浅色主题切换,插件需要适配宿主的视觉风格,保持界面一致性。
---
## 🎨 主题系统概述
阑山桌面使用 Avalonia UI 的主题系统,支持:
- **浅色主题** - 明亮背景,深色文字
- **深色主题** - 深色背景,浅色文字
- **跟随系统** - 自动匹配 Windows/macOS 主题
---
## 🌗 检测当前主题
### 在组件中检测
```csharp
using Avalonia;
using Avalonia.Styling;
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 检测当前主题
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
// 根据主题设置颜色
UpdateTheme(isDark);
// 监听主题变化
if (Application.Current != null)
{
Application.Current.ActualThemeVariantChanged += (_, _) =>
{
var newIsDark = Application.Current.ActualThemeVariant == ThemeVariant.Dark;
UpdateTheme(newIsDark);
};
}
}
private void UpdateTheme(bool isDark)
{
Background = new SolidColorBrush(
isDark ? Color.Parse("#FF1E1E1E") : Color.Parse("#FFFFFFFF"));
Foreground = new SolidColorBrush(
isDark ? Colors.White : Colors.Black);
}
}
```
---
## 📐 圆角系统
插件必须使用宿主提供的圆角系统,确保与内置组件视觉一致。
### 为什么插件不能使用 XAML 资源
插件运行在独立的 `AssemblyLoadContext` 中,无法直接访问宿主的资源字典。因此 `{DynamicResource DesignCornerRadiusComponent}` 在插件 XAML 中无效。
### 使用代码设置圆角
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 方法 1使用预设推荐
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 方法 2带最小/最大值限制
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component,
minimum: new CornerRadius(8),
maximum: new CornerRadius(24));
// 方法 3自定义基础值应用全局缩放
CornerRadius = context.Appearance.ResolveScaledCornerRadius(
baseRadius: 16,
minimum: 8,
maximum: 32);
}
}
```
### 圆角预设
| 预设 | 默认值 | 用途 |
|-----|-------|------|
| `Micro` | 6px | 微小元素 |
| `Xs` | 12px | 小元素、图标容器 |
| `Sm` | 14px | 小卡片 |
| `Md` | 20px | 普通按钮/卡片 |
| `Lg` | 28px | 大面板 |
| `Xl` | 32px | 强调容器 |
| `Island` | 36px | 大型容器 |
| `Component` | 18px | **桌面组件标准** |
| `Default` | 自适应 | 根据尺寸自动计算 |
### 内部元素圆角
组件内部的卡片、按钮应使用更小的圆角:
```csharp
// 组件根容器 - 使用 Component 预设
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
// 内部卡片 - 使用 Md 预设
var innerCard = new Border
{
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Md),
Background = new SolidColorBrush(Colors.LightGray)
};
// 按钮 - 使用 Sm 预设
var button = new Button
{
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Sm)
};
```
---
## 🎨 颜色系统
### 推荐的颜色策略
```csharp
// 透明背景(推荐)- 让宿主壁纸透出
Background = new SolidColorBrush(Colors.Transparent);
// 毛玻璃效果
Background = new SolidColorBrush(Color.Parse(isDark ? "#40FFFFFF" : "#40000000"));
// 卡片背景
Background = new SolidColorBrush(Color.Parse(isDark ? "#FF2D2D2D" : "#FFFFFFFF"));
// 强调色(使用系统强调色)
var accentColor = Color.Parse("#FF0078D4"); // 阑山桌面主色调
```
### 文字颜色
```csharp
// 主要文字
Foreground = new SolidColorBrush(isDark ? Colors.White : Colors.Black);
// 次要文字
Foreground = new SolidColorBrush(isDark ? Color.Parse("#FFCCCCCC") : Color.Parse("#FF666666"));
// 禁用文字
Foreground = new SolidColorBrush(isDark ? Color.Parse("#FF666666") : Color.Parse("#FF999999"));
```
---
## 🔄 响应外观变化
### 订阅外观变化事件
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 订阅外观变化
context.Appearance.AppearanceChanged += (_, _) =>
{
UpdateAppearance();
};
// 初始化
UpdateAppearance();
}
private void UpdateAppearance()
{
// 重新应用圆角(用户可能调整了全局圆角设置)
var context = ...; // 获取 context
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 更新主题颜色
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
UpdateThemeColors(isDark);
}
}
```
---
## 🧩 使用 FluentAvalonia 控件
推荐使用 FluentAvalonia 控件库,它们自动适配主题:
```xml
<Window xmlns:ui="using:FluentAvalonia.UI.Controls">
<ui:SettingsExpander Header="设置项">
<ui:SettingsExpander.IconSource>
<ui:FontIconSource Glyph="&#xE713;" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
</Window>
```
### 常用 FluentAvalonia 控件
| 控件 | 用途 |
|-----|------|
| `SettingsExpander` | 设置项展开器 |
| `SettingsCard` | 设置卡片 |
| `ColorPicker` | 颜色选择器 |
| `NumberBox` | 数字输入框 |
| `ToggleSwitch` | 开关 |
---
## 💡 最佳实践
### 1. 始终使用透明背景
```csharp
// ✅ 好的做法
Background = new SolidColorBrush(Colors.Transparent);
// ❌ 避免硬编码背景色
Background = new SolidColorBrush(Colors.White);
```
### 2. 组件根容器必须使用 Component 圆角
```csharp
// ✅ 正确
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
// ❌ 错误 - 硬编码
CornerRadius = new CornerRadius(18);
```
### 3. 响应主题变化
```csharp
// ✅ 订阅变化事件
Application.Current.ActualThemeVariantChanged += OnThemeChanged;
// ❌ 只在构造函数中设置一次
```
### 4. 使用语义化颜色
```csharp
// ✅ 根据用途选择颜色
var primaryText = isDark ? Colors.White : Colors.Black;
var secondaryText = isDark ? Color.Parse("#FFCCCCCC") : Color.Parse("#FF666666");
// ❌ 避免随意使用颜色
var textColor = Color.Parse("#FF123456");
```
---
## 🐛 常见问题
### 问题 1圆角不生效
**原因:** 在 XAML 中使用 `{DynamicResource}`
**解决:** 在代码中设置圆角(见上文)
### 问题 2主题切换后颜色不对
**原因:** 没有订阅主题变化事件
**解决:** 添加 `ActualThemeVariantChanged` 事件处理
### 问题 3组件与内置组件风格不一致
**排查:**
1. 检查圆角是否使用 `PluginCornerRadiusPreset.Component`
2. 检查背景是否透明
3. 检查是否使用了 FluentAvalonia 控件
---
## 📚 参考资源
- [CORNER_RADIUS_SPEC.md](../../docs/CORNER_RADIUS_SPEC.md)
- [VISUAL_SPEC.md](../../docs/VISUAL_SPEC.md)
- [FluentAvalonia 文档](https://github.com/amwx/FluentAvalonia)
---
## 🎯 下一步
学习插件间通信:
👉 **[05-插件间通信](05-插件间通信.md)** - 与其他插件协作
---
*最后更新2026年4月*

View File

@@ -0,0 +1,375 @@
# 05-插件间通信
插件之间可以通过消息总线和服务导出进行通信,实现功能协作和数据共享。
---
## 🎯 通信方式概述
| 方式 | 适用场景 | 方向 |
|-----|---------|------|
| **消息总线** | 事件通知、广播 | 多对多 |
| **服务导出** | 功能共享、API 暴露 | 一对多 |
| **共享契约** | 数据交换 | 双向 |
---
## 📢 消息总线
使用 `IPluginMessageBus` 发布和订阅消息。
### 发布消息
```csharp
public class WeatherService
{
private readonly IPluginMessageBus _messageBus;
public WeatherService(IPluginMessageBus messageBus)
{
_messageBus = messageBus;
}
public async Task UpdateWeatherAsync()
{
var weather = await FetchWeatherAsync();
// 发布天气更新消息
_messageBus.Publish(new WeatherUpdatedMessage
{
City = weather.City,
Temperature = weather.Temperature,
Condition = weather.Condition
});
}
}
// 定义消息
public class WeatherUpdatedMessage
{
public string City { get; set; } = "";
public double Temperature { get; set; }
public string Condition { get; set; } = "";
}
```
### 订阅消息
```csharp
public class AnotherPluginService
{
public AnotherPluginService(IPluginMessageBus messageBus)
{
// 订阅天气更新消息
messageBus.Subscribe<WeatherUpdatedMessage>(OnWeatherUpdated);
}
private void OnWeatherUpdated(WeatherUpdatedMessage message)
{
Console.WriteLine($"收到天气更新: {message.City} {message.Temperature}°C");
// 根据天气更新自己的状态
UpdateDisplay(message);
}
}
```
### 取消订阅
```csharp
public class MyService : IDisposable
{
private readonly IPluginMessageBus _messageBus;
private readonly Guid _subscriptionId;
public MyService(IPluginMessageBus messageBus)
{
_messageBus = messageBus;
_subscriptionId = messageBus.Subscribe<WeatherUpdatedMessage>(OnWeatherUpdated);
}
public void Dispose()
{
// 取消订阅,避免内存泄漏
_messageBus.Unsubscribe<WeatherUpdatedMessage>(_subscriptionId);
}
}
```
---
## 🔌 服务导出
插件可以将服务导出,供其他插件使用。
### 导出服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
// 导出服务供其他插件使用
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
description: "提供天气查询服务");
}
// 定义服务接口
public interface IWeatherService
{
Task<WeatherInfo> GetCurrentWeatherAsync(string city);
Task<List<WeatherForecast>> GetForecastAsync(string city, int days);
}
// 实现服务
public class WeatherService : IWeatherService
{
public async Task<WeatherInfo> GetCurrentWeatherAsync(string city)
{
// 实现天气查询
}
public async Task<List<WeatherForecast>> GetForecastAsync(string city, int days)
{
// 实现天气预报
}
}
```
### 使用其他插件的服务
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 获取其他插件导出的服务
var weatherService = context.ServiceProvider
.GetExportedService<IWeatherService>("MyPlugin.WeatherService");
if (weatherService != null)
{
// 使用服务
LoadWeatherAsync(weatherService);
}
}
private async void LoadWeatherAsync(IWeatherService weatherService)
{
var weather = await weatherService.GetCurrentWeatherAsync("北京");
UpdateUI(weather);
}
}
```
### 服务导出选项
```csharp
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
description: "提供天气查询服务",
version: "1.0.0",
isPublic: true); // 是否公开给其他插件
```
---
## 📦 共享契约
通过 `sharedContracts` 在插件间共享类型定义。
### 定义共享契约
```csharp
// 在共享类库项目中定义
namespace MyPlugin.Shared;
public interface IWeatherData
{
string City { get; }
double Temperature { get; }
string Condition { get; }
}
public class WeatherData : IWeatherData
{
public string City { get; set; } = "";
public double Temperature { get; set; }
public string Condition { get; set; } = "";
}
```
### 在 plugin.json 中声明
```json
{
"id": "com.example.weather",
"name": "天气插件",
"sharedContracts": [
"MyPlugin.Shared.IWeatherData",
"MyPlugin.Shared.WeatherData"
]
}
```
### 使用共享类型
```csharp
// 插件 A 发布数据
public class WeatherService
{
public WeatherData GetWeather()
{
return new WeatherData
{
City = "北京",
Temperature = 25.5,
Condition = "晴"
};
}
}
// 插件 B 接收数据
public class ConsumerService
{
public void ProcessWeather(IWeatherData weather)
{
Console.WriteLine($"{weather.City}: {weather.Temperature}°C");
}
}
```
---
## 🔒 安全考虑
### 服务导出安全
```csharp
// 只导出必要的接口,不暴露实现细节
public interface IPublicApi
{
Task<Data> GetDataAsync();
}
internal class InternalService : IPublicApi
{
// 内部实现细节不暴露
private readonly SecretKey _key;
public async Task<Data> GetDataAsync()
{
// 实现
}
}
```
### 消息验证
```csharp
private void OnMessageReceived(MyMessage message)
{
// 验证消息来源
if (message.SenderId != "TrustedPlugin")
{
return; // 忽略不信任来源的消息
}
// 处理消息
}
```
---
## 💡 最佳实践
### 1. 使用接口定义服务契约
```csharp
// ✅ 好的做法 - 定义接口
public interface IWeatherService { }
// ❌ 避免 - 直接导出实现类
services.AddPluginServiceExport<WeatherService>(...);
```
### 2. 处理服务不可用情况
```csharp
// ✅ 优雅处理服务缺失
var service = context.ServiceProvider
.GetExportedService<IWeatherService>("key");
if (service == null)
{
// 显示提示或降级处理
ShowServiceUnavailableMessage();
return;
}
```
### 3. 及时取消消息订阅
```csharp
// ✅ 在 Dispose 中取消订阅
public void Dispose()
{
_messageBus.Unsubscribe<MyMessage>(_subscriptionId);
}
```
### 4. 版本兼容性
```csharp
// 在服务导出中包含版本信息
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
version: "2.0.0", // 语义化版本
description: "天气服务 v2");
```
---
## 🐛 常见问题
### 问题 1消息收不到
**排查:**
1. 确认消息类型完全一致(包括命名空间)
2. 检查订阅是否在消息发布之前
3. 确认没有取消订阅
### 问题 2服务找不到
**排查:**
1. 确认服务已导出(`AddPluginServiceExport`
2. 检查 `serviceKey` 是否正确
3. 确认依赖的插件已安装并启用
### 问题 3类型转换错误
**原因:** 共享契约类型不匹配
**解决:** 确保所有插件使用相同版本的共享契约程序集
---
## 📚 参考资源
- [IPluginMessageBus 源码](../../LanMountainDesktop.PluginSdk/IPluginMessageBus.cs)
- [IPluginExportRegistry 源码](../../LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs)
- [Shared.Contracts](../../LanMountainDesktop.Shared.Contracts/)
---
## 🎯 下一步
查看实战案例:
👉 **[01-开发天气组件](../04-实战案例/01-开发天气组件.md)** - 完整插件开发流程
---
*最后更新2026年4月*

View File

@@ -0,0 +1,307 @@
# 01-PluginBase详解
`PluginBase` 是插件的基类,提供基础功能和生命周期管理。本文详细讲解其用法和扩展点。
---
## 🎯 PluginBase 概述
```csharp
public abstract class PluginBase : IPlugin
{
// 日志记录器
protected ILogger? Logger { get; }
// 初始化方法(必须实现)
public abstract void Initialize(HostBuilderContext context, IServiceCollection services);
}
```
---
## 📝 基本用法
### 最小实现
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 插件初始化逻辑
}
}
```
---
## 🔧 Initialize 方法详解
### 方法签名
```csharp
public abstract void Initialize(
HostBuilderContext context, // 宿主构建上下文
IServiceCollection services // 服务注册集合
);
```
### 参数说明
| 参数 | 类型 | 用途 |
|-----|------|------|
| `context` | `HostBuilderContext` | 访问宿主配置、环境信息 |
| `services` | `IServiceCollection` | 注册服务、组件、设置页面 |
### context 使用示例
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 访问配置
var configValue = context.Configuration["MySetting"];
// 判断运行环境
var isDevelopment = context.HostingEnvironment.IsDevelopment();
// 获取应用名称
var appName = context.HostingEnvironment.ApplicationName;
}
```
---
## 📝 日志记录
### 使用 Logger 属性
```csharp
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 记录日志
Logger?.LogInformation("插件初始化开始");
try
{
// 初始化逻辑
services.AddSingleton<IMyService, MyService>();
Logger?.LogInformation("插件初始化完成");
}
catch (Exception ex)
{
Logger?.LogError(ex, "插件初始化失败");
throw;
}
}
}
```
### 日志级别
```csharp
Logger?.LogTrace("详细跟踪信息");
Logger?.LogDebug("调试信息");
Logger?.LogInformation("一般信息");
Logger?.LogWarning("警告信息");
Logger?.LogError("错误信息");
Logger?.LogCritical("严重错误");
```
---
## 🔌 服务注册
### 注册单例服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 单例 - 整个应用生命周期只有一个实例
services.AddSingleton<IWeatherService, WeatherService>();
}
```
### 注册作用域服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 作用域 - 每个作用域一个实例
services.AddScoped<IDataContext, DataContext>();
}
```
### 注册瞬态服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 瞬态 - 每次请求都创建新实例
services.AddTransient<IValidator, Validator>();
}
```
### 带配置的服务注册
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<IWeatherService>(provider =>
{
var httpClient = provider.GetRequiredService<HttpClient>();
var logger = provider.GetRequiredService<ILogger<WeatherService>>();
var apiKey = context.Configuration["WeatherApiKey"];
return new WeatherService(httpClient, logger, apiKey);
});
}
```
---
## 🧩 完整示例
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace WeatherPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Logger?.LogInformation("天气插件初始化开始");
try
{
// 1. 注册 HTTP 客户端
services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});
// 2. 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
services.AddSingleton<ILocationService, LocationService>();
// 3. 注册桌面组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "WeatherPlugin.Widget",
DisplayName = "天气",
IconKey = "Weather",
Category = "信息",
MinWidthCells = 4,
MinHeightCells = 3
});
// 4. 注册设置页面
services.AddPluginSettingsSection(
"weather-settings",
"天气设置",
section => section
.AddText("api_key", "API密钥", isPassword: true)
.AddText("default_city", "默认城市", defaultValue: "北京")
.AddToggle("auto_refresh", "自动刷新", defaultValue: true)
.AddNumber("refresh_interval", "刷新间隔(分钟)",
defaultValue: 30, minimum: 5, maximum: 120),
iconKey: "Settings");
Logger?.LogInformation("天气插件初始化完成");
}
catch (Exception ex)
{
Logger?.LogError(ex, "天气插件初始化失败");
throw;
}
}
}
```
---
## 💡 最佳实践
### 1. 使用 try-catch 包装初始化逻辑
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
try
{
// 初始化逻辑
}
catch (Exception ex)
{
Logger?.LogError(ex, "初始化失败");
throw; // 重新抛出,让宿主知道初始化失败
}
}
```
### 2. 按依赖顺序注册服务
```csharp
// ✅ 先注册被依赖的服务
services.AddSingleton<IDataService, DataService>();
// 再注册依赖它们的服务
services.AddSingleton<IWeatherService, WeatherService>(); // 依赖 IDataService
// 最后注册组件
services.AddPluginDesktopComponent<WeatherWidget>(options);
```
### 3. 记录初始化过程
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Logger?.LogInformation("开始初始化...");
Logger?.LogDebug("注册服务...");
services.AddSingleton<IMyService, MyService>();
Logger?.LogDebug("注册组件...");
services.AddPluginDesktopComponent<MyWidget>(options);
Logger?.LogInformation("初始化完成");
}
```
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [IPlugin 接口](../../LanMountainDesktop.PluginSdk/IPlugin.cs)
- [Microsoft.Extensions.DependencyInjection 文档](https://docs.microsoft.com/dotnet/api/microsoft.extensions.dependencyinjection)
---
## 🎯 下一步
学习组件注册 API
👉 **[02-组件注册与配置](02-组件注册与配置.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,182 @@
# 02-组件注册与配置
`AddPluginDesktopComponent` 是注册桌面组件的核心 API。本文详细讲解其用法和配置选项。
---
## 🎯 API 概览
```csharp
public static IServiceCollection AddPluginDesktopComponent<TComponent>(
this IServiceCollection services,
PluginDesktopComponentOptions options)
where TComponent : class, IControl
```
---
## 📋 基本用法
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.MyWidget",
DisplayName = "我的组件",
IconKey = "Home",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3
});
}
```
---
## 🔧 PluginDesktopComponentOptions
### 完整属性列表
| 属性 | 类型 | 必需 | 说明 |
|-----|------|------|------|
| `ComponentId` | `string` | ✅ | 唯一标识符 |
| `DisplayName` | `string` | ✅ | 显示名称 |
| `IconKey` | `string` | ✅ | 图标键名 |
| `Category` | `string` | ✅ | 分类 |
| `MinWidthCells` | `int` | ✅ | 最小宽度(格) |
| `MinHeightCells` | `int` | ✅ | 最小高度(格) |
| `CornerRadiusPreset` | `PluginCornerRadiusPreset` | ❌ | 圆角预设 |
| `ResizeMode` | `PluginDesktopComponentResizeMode` | ❌ | 调整大小模式 |
### ComponentId
```csharp
ComponentId = "MyPlugin.WeatherWidget"
```
- 必须唯一
- 建议使用 `插件ID.组件名` 格式
- 一经发布不可更改
### DisplayName
```csharp
DisplayName = "天气"
```
- 显示在组件库中
- 支持本地化(通过资源文件)
### IconKey
```csharp
IconKey = "Weather"
```
使用 [Fluent UI System Icons](https://github.com/microsoft/fluentui-system-icons) 的图标名。
### Category
```csharp
Category = "工具"
```
常用分类:
- `工具` - 实用工具
- `信息` - 信息展示
- `娱乐` - 娱乐相关
- `系统` - 系统监控
### MinWidthCells / MinHeightCells
```csharp
MinWidthCells = 4, // 4格宽约240像素
MinHeightCells = 3 // 3格高约180像素
```
---
## 🎨 圆角配置
```csharp
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Widget",
DisplayName = "我的组件",
IconKey = "Home",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3,
CornerRadiusPreset = PluginCornerRadiusPreset.Component
});
```
---
## 📐 调整大小模式
```csharp
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
// ...
ResizeMode = PluginDesktopComponentResizeMode.Free
});
```
| 模式 | 说明 |
|-----|------|
| `Free` | 自由调整大小 |
| `Fixed` | 固定大小 |
| `AspectRatio` | 保持宽高比 |
---
## 🧩 完整示例
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 天气组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "WeatherPlugin.Widget",
DisplayName = "天气",
IconKey = "Weather",
Category = "信息",
MinWidthCells = 4,
MinHeightCells = 3,
CornerRadiusPreset = PluginCornerRadiusPreset.Component,
ResizeMode = PluginDesktopComponentResizeMode.Free
});
// 时钟组件
services.AddPluginDesktopComponent<ClockWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "ClockPlugin.Widget",
DisplayName = "时钟",
IconKey = "Clock",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 4,
CornerRadiusPreset = PluginCornerRadiusPreset.Component,
ResizeMode = PluginDesktopComponentResizeMode.AspectRatio
});
}
```
---
## 📚 参考资源
- [PluginDesktopComponentOptions 源码](../../LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs)
- [02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,147 @@
# 03-设置API详解
设置 API 允许插件添加配置页面和持久化用户设置。
---
## 🎯 API 概览
### 声明式设置
```csharp
services.AddPluginSettingsSection(
string sectionId,
string displayName,
Action<PluginSettingsSectionBuilder> configure,
string iconKey);
```
### 自定义设置页
```csharp
services.AddPluginSettingsSection<TPage>(
string sectionId,
string displayName,
string iconKey)
where TPage : SettingsPageBase;
```
---
## 📋 声明式设置详解
### 基本用法
```csharp
services.AddPluginSettingsSection(
"myplugin-settings",
"我的设置",
section => section
.AddToggle("enabled", "启用", defaultValue: true)
.AddText("name", "名称", defaultValue: ""),
iconKey: "Settings");
```
### 设置类型
#### Toggle开关
```csharp
.AddToggle(
key: "auto_update",
displayName: "自动更新",
defaultValue: true,
description: "启动时检查更新")
```
#### Text文本
```csharp
.AddText(
key: "api_key",
displayName: "API密钥",
defaultValue: "",
placeholder: "请输入",
isPassword: false)
```
#### Number数值
```csharp
.AddNumber(
key: "interval",
displayName: "刷新间隔",
defaultValue: 60,
minimum: 10,
maximum: 3600,
increment: 10)
```
#### Select选择
```csharp
.AddSelect(
key: "theme",
displayName: "主题",
choices: new[]
{
new SettingsOptionChoice("light", "浅色"),
new SettingsOptionChoice("dark", "深色")
},
defaultValue: "light")
```
#### Path路径
```csharp
.AddPath(
key: "save_path",
displayName: "保存路径",
defaultValue: "",
pathType: SettingsPathType.Folder,
dialogTitle: "选择文件夹")
```
---
## 🔧 读取和保存设置
### 使用 IPluginSettingsService
```csharp
public class MyService
{
private readonly IPluginSettingsService _settings;
public MyService(IPluginSettingsService settings)
{
_settings = settings;
// 读取
var value = _settings.GetValue<string>("key", "default");
// 保存
_settings.SetValue("key", "new value");
// 监听变化
_settings.SettingsChanged += (s, e) =>
{
if (e.Key == "key")
{
HandleChange(e.NewValue);
}
};
}
}
```
---
## 📚 参考资源
- [IPluginSettingsService 源码](../../LanMountainDesktop.PluginSdk/IPluginSettingsService.cs)
- [03-设置系统集成](../02-核心概念与原理/03-设置系统集成.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,106 @@
# 04-外观API详解
外观 API 提供圆角、主题等视觉相关的功能。
---
## 🎯 IPluginAppearanceContext
```csharp
public interface IPluginAppearanceContext
{
// 获取圆角值
CornerRadius ResolveCornerRadius(PluginCornerRadiusPreset preset);
// 获取带限制的圆角值
CornerRadius ResolveCornerRadius(
PluginCornerRadiusPreset preset,
CornerRadius? minimum,
CornerRadius? maximum);
// 获取缩放后的圆角值
CornerRadius ResolveScaledCornerRadius(
double baseRadius,
double? minimum,
double? maximum);
// 外观变化事件
event EventHandler? AppearanceChanged;
}
```
---
## 📐 圆角 API
### 获取圆角值
```csharp
public MyWidget(PluginDesktopComponentContext context)
{
// 使用预设
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
}
```
### 带限制的圆角
```csharp
var radius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component,
minimum: new CornerRadius(8),
maximum: new CornerRadius(24));
```
### 缩放圆角
```csharp
var radius = context.Appearance.ResolveScaledCornerRadius(
baseRadius: 16,
minimum: 8,
maximum: 32);
```
---
## 🎨 圆角预设
| 预设 | 值 | 用途 |
|-----|---|------|
| Micro | 6px | 微小元素 |
| Xs | 12px | 小元素 |
| Sm | 14px | 小卡片 |
| Md | 20px | 普通按钮 |
| Lg | 28px | 大面板 |
| Xl | 32px | 强调容器 |
| Island | 36px | 大型容器 |
| Component | 18px | 桌面组件 |
| Default | 自适应 | 自动计算 |
---
## 🔄 响应外观变化
```csharp
public MyWidget(PluginDesktopComponentContext context)
{
context.Appearance.AppearanceChanged += (_, _) =>
{
// 重新应用圆角
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
};
}
```
---
## 📚 参考资源
- [IPluginAppearanceContext 源码](../../LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs)
- [04-外观与主题系统](../02-核心概念与原理/04-外观与主题系统.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,73 @@
# 05-本地化支持
本地化 API 支持多语言资源管理。
---
## 🎯 资源文件
### 文件位置
```
Localization/
├── zh-CN.json # 简体中文
├── en-US.json # 英文
├── ja-JP.json # 日文
└── ko-KR.json # 韩文
```
### 资源格式
```json
{
"PluginName": "我的插件",
"Settings": {
"Title": "设置",
"Save": "保存"
},
"Messages": {
"Hello": "你好,{0}!",
"Error": "错误:{0}"
}
}
```
---
## 📝 使用本地化
### 注入 IStringLocalizer
```csharp
public class MyService
{
private readonly IStringLocalizer<MyService> _localizer;
public MyService(IStringLocalizer<MyService> localizer)
{
_localizer = localizer;
}
public void DoWork()
{
// 简单字符串
var name = _localizer["PluginName"];
// 带参数
var message = _localizer["Messages.Hello", "用户"];
// 嵌套键
var title = _localizer["Settings.Title"];
}
}
```
---
## 📚 参考资源
- [Microsoft.Extensions.Localization 文档](https://docs.microsoft.com/dotnet/api/microsoft.extensions.localization)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,215 @@
# 01-GitHub Actions入门
GitHub Actions 是自动化构建、测试和发布插件的强大工具。本文介绍如何为插件项目配置 CI/CD 流程。
---
## 🎯 什么是 GitHub Actions
GitHub Actions 是 GitHub 提供的持续集成/持续部署CI/CD服务可以
- ✅ 自动构建插件
- ✅ 运行单元测试
- ✅ 打包 .laapp 文件
- ✅ 自动发布到 GitHub Releases
---
## 📁 工作流文件位置
```
.github/workflows/
├── build.yml # 构建工作流
├── release.yml # 发布工作流
└── code-quality.yml # 代码质量检查
```
---
## 🚀 基础工作流示例
### 最简单的构建工作流
```yaml
# .github/workflows/build.yml
name: Build Plugin
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: windows-latest
steps:
# 1. 检出代码
- name: Checkout
uses: actions/checkout@v4
# 2. 设置 .NET
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# 3. 还原依赖
- name: Restore
run: dotnet restore
# 4. 构建
- name: Build
run: dotnet build --configuration Release --no-restore
# 5. 上传构建产物
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-package
path: bin/Release/net10.0/*.laapp
```
---
## 📋 工作流详解
### 触发条件on
```yaml
on:
# 推送到指定分支时触发
push:
branches: [main, master, develop]
# 创建 Pull Request 时触发
pull_request:
branches: [main, master]
# 手动触发
workflow_dispatch:
# 定时触发每天凌晨2点
schedule:
- cron: '0 2 * * *'
# 创建标签时触发(用于发布)
push:
tags:
- 'v*'
```
### 运行环境runs-on
```yaml
jobs:
build:
runs-on: windows-latest # Windows 环境
# 或
runs-on: ubuntu-latest # Linux 环境
# 或
runs-on: macos-latest # macOS 环境
```
### 矩阵构建(多平台)
```yaml
jobs:
build:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
dotnet: ['10.0.x']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet }}
```
---
## 🔧 常用 Actions
### 检出代码
```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史(用于生成版本号)
```
### 设置 .NET
```yaml
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
```
### 上传产物
```yaml
- uses: actions/upload-artifact@v4
with:
name: plugin-package
path: bin/Release/net10.0/*.laapp
retention-days: 30 # 保留30天
```
### 下载产物
```yaml
- uses: actions/download-artifact@v4
with:
name: plugin-package
path: ./artifacts
```
---
## 💡 最佳实践
### 1. 缓存依赖
```yaml
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
```
### 2. 使用语义化版本
```yaml
- name: Get Version
id: version
run: |
VERSION=$(echo ${GITHUB_REF#refs/tags/} | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
```
### 3. 条件执行
```yaml
- name: Deploy
if: github.ref == 'refs/heads/main' # 只在 main 分支执行
run: echo "Deploying..."
```
---
## 🎯 下一步
学习自动打包配置:
👉 **[02-配置自动构建](02-配置自动构建.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,130 @@
# 02-配置自动构建
配置 GitHub Actions 自动构建插件项目。
---
## 🎯 完整构建工作流
```yaml
# .github/workflows/build.yml
name: Build
on:
push:
branches: [main, master]
paths-ignore:
- '**.md'
- '.gitignore'
pull_request:
branches: [main, master]
env:
DOTNET_VERSION: '10.0.x'
CONFIGURATION: 'Release'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-${{ github.run_number }}
path: bin/${{ env.CONFIGURATION }}/net10.0/*.laapp
```
---
## 🔧 关键配置说明
### 路径过滤
```yaml
on:
push:
paths-ignore:
- '**.md' # 忽略文档修改
- '.gitignore' # 忽略 gitignore 修改
- 'docs/**' # 忽略 docs 文件夹
```
### 环境变量
```yaml
env:
DOTNET_VERSION: '10.0.x'
CONFIGURATION: 'Release'
PLUGIN_NAME: 'MyPlugin'
```
### 构建步骤
```yaml
steps:
# 1. 检出
- uses: actions/checkout@v4
# 2. 设置 .NET
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# 3. 缓存
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/*.csproj') }}
# 4. 还原
- run: dotnet restore
# 5. 构建
- run: dotnet build -c ${{ env.CONFIGURATION }} --no-restore
# 6. 测试
- run: dotnet test --no-build
# 7. 上传
- uses: actions/upload-artifact@v4
with:
name: plugin
path: bin/Release/net10.0/*.laapp
```
---
## 📚 参考资源
- [GitHub Actions 文档](https://docs.github.com/actions)
- [.NET CI/CD 指南](https://docs.microsoft.com/dotnet/devops/github-actions-overview)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,156 @@
# 03-自动打包与发布
配置 GitHub Actions 自动打包 .laapp 并发布到 GitHub Releases。
---
## 🎯 发布工作流
```yaml
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
env:
DOTNET_VERSION: '10.0.x'
jobs:
build-and-release:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Get Version
id: version
run: |
$version = $env:GITHUB_REF -replace 'refs/tags/v', ''
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Package
run: |
$version = "${{ steps.version.outputs.VERSION }}"
Rename-Item -Path "bin/Release/net10.0/MyPlugin.laapp" -NewName "MyPlugin-$version.laapp"
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: bin/Release/net10.0/*.laapp
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
---
## 📋 发布流程
### 1. 创建标签
```bash
# 创建版本标签
git tag -a v1.0.0 -m "Release version 1.0.0"
# 推送标签到 GitHub
git push origin v1.0.0
```
### 2. 自动触发
推送标签后GitHub Actions 会自动:
1. 检出代码
2. 构建项目
3. 打包 .laapp
4. 创建 Release
5. 上传产物
### 3. 查看 Release
在 GitHub 仓库页面 → Releases 查看自动创建的发布。
---
## 🔧 高级配置
### 预发布版本
```yaml
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: bin/Release/net10.0/*.laapp
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
```
### 生成变更日志
```yaml
- name: Generate Changelog
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
configuration: .github/changelog-config.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.changelog.outputs.changelog }}
files: bin/Release/net10.0/*.laapp
```
---
## 💡 最佳实践
### 版本号管理
```yaml
- name: Update Version
run: |
$version = "${{ github.ref_name }}" -replace '^v', ''
$json = Get-Content plugin.json | ConvertFrom-Json
$json.version = $version
$json | ConvertTo-Json | Set-Content plugin.json
```
### 多文件发布
```yaml
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
bin/Release/net10.0/*.laapp
README.md
LICENSE
```
---
## 🎯 下一步
学习多平台构建:
👉 **[04-多平台构建策略](04-多平台构建策略.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,92 @@
# 01-插件打包规范
了解 .laapp 包的规范和结构,确保插件能正确安装和运行。
---
## 📦 .laapp 文件格式
`.laapp` 是阑山桌面的插件包格式,本质上是一个 **ZIP 压缩包**
### 文件结构
```
MyPlugin.laapp
├── plugin.json # 插件清单(必需)
├── MyPlugin.dll # 主程序集(必需)
├── Localization/ # 本地化文件夹
│ ├── zh-CN.json
│ └── en-US.json
└── *.dll # 依赖项
```
---
## 📋 plugin.json 规范
### 必需字段
```json
{
"id": "com.example.myplugin",
"name": "我的插件",
"description": "插件描述",
"author": "作者名",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyPlugin.dll",
"sharedContracts": []
}
```
### 字段验证规则
| 字段 | 规则 |
|-----|------|
| `id` | 小写字母、数字、点号,反向域名格式 |
| `version` | 语义化版本x.y.z |
| `apiVersion` | 必须与 SDK 版本兼容 |
| `entranceAssembly` | 必须与 DLL 文件名一致 |
---
## 🔨 构建配置
### .csproj 关键配置
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
<!-- 确保资源文件复制到输出目录 -->
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
```
---
## ✅ 打包检查清单
- [ ] `plugin.json` 格式正确
- [ ] `id` 全局唯一
- [ ] `apiVersion` 与 SDK 版本匹配
- [ ] DLL 文件名与 `entranceAssembly` 一致
- [ ] 所有依赖项已包含
- [ ] 本地化文件完整
---
*最后更新2026年4月*

View File

@@ -0,0 +1,75 @@
# 02-版本管理策略
合理的版本管理是插件维护的基础。
---
## 🎯 语义化版本SemVer
版本格式:`主版本.次版本.修订号`
| 版本变化 | 说明 | 示例 |
|---------|------|------|
| 主版本Major | 破坏性变更 | 1.0.0 → 2.0.0 |
| 次版本Minor | 新功能,向后兼容 | 1.0.0 → 1.1.0 |
| 修订号Patch | Bug 修复 | 1.0.0 → 1.0.1 |
---
## 📋 版本示例
| 版本 | 含义 |
|-----|------|
| `1.0.0` | 首个正式版 |
| `1.1.0` | 新增功能 |
| `1.1.1` | 修复 Bug |
| `2.0.0-beta` | 2.0 测试版 |
| `2.0.0-rc1` | 2.0 候选版 |
---
## 🔄 版本更新流程
### 1. 更新版本号
```json
// plugin.json
{
"version": "1.1.0"
}
```
### 2. 更新 CHANGELOG.md
```markdown
## [1.1.0] - 2024-04-13
### 新增
- 添加天气预警功能
- 支持多城市管理
### 修复
- 修复定位失败问题
```
### 3. 创建 Git 标签
```bash
git add .
git commit -m "Release v1.1.0"
git tag -a v1.1.0 -m "Release version 1.1.0"
git push origin main --tags
```
---
## 💡 最佳实践
- 使用 GitHub Releases 管理版本
- 每个版本都写更新日志
- 测试版使用 `-beta``-alpha` 后缀
- 保持向后兼容,避免频繁主版本升级
---
*最后更新2026年4月*

View File

@@ -0,0 +1,71 @@
# 03-发布到插件市场
将插件发布到阑山桌面插件市场,让更多用户使用。
---
## 🎯 发布流程
### 1. 准备材料
- 插件包(.laapp
- 插件图标256x256 PNG
- 截图(至少 1 张)
- 详细描述
- 更新日志
### 2. 提交审核
1. 访问阑山桌面开发者门户
2. 登录开发者账号
3. 点击「提交新插件」
4. 填写插件信息
5. 上传 .laapp 文件
6. 提交审核
### 3. 审核标准
| 检查项 | 要求 |
|-------|------|
| 功能完整性 | 插件能正常运行 |
| 安全性 | 无恶意代码 |
| 用户体验 | 界面美观,操作流畅 |
| 文档完整 | 有基本使用说明 |
---
## 📋 元数据要求
### 必需信息
```json
{
"id": "com.example.plugin",
"name": "插件名称",
"description": "简短描述50字以内",
"author": "作者名",
"version": "1.0.0",
"tags": ["工具", "天气"],
"website": "https://github.com/..."
}
```
### 图标规范
- 格式PNG
- 尺寸256x256 像素
- 背景:透明
- 风格:与阑山桌面一致
---
## 🚀 发布后
- 关注用户反馈
- 及时修复 Bug
- 定期更新功能
- 维护更新日志
---
*最后更新2026年4月*

Some files were not shown because too many files have changed in this diff Show More