Compare commits

...

50 Commits

Author SHA1 Message Date
lincube
9283da5940 changed.调整了启动逻辑,优化了更新页面。 2026-04-17 22:33:41 +08:00
lincube
9efa43d92b Update LanMountainDesktop.csproj 2026-04-17 18:30:44 +08:00
lincube
53ff98f66d Update build.yml 2026-04-17 17:50:02 +08:00
lincube
6c526ffdd2 fix.ci难修,为什么liunx跑不起来呢? 2026-04-17 17:39:31 +08:00
lincube
3957d81948 fix.修CI,好像是因为Linux那边有个问题,反正修就对了。 2026-04-17 17:03:13 +08:00
lincube
81ee19f360 feat.尝试弄了AOT的启动器。 2026-04-17 15:16:01 +08:00
lincube
59c4824425 fix.启动器一定要能够启动 2026-04-16 19:28:58 +08:00
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
lincube
ce5acf5bd7 fix.修复了快捷方式组件无法正常透明的问题。 2026-04-13 16:26:23 +08:00
lincube
b933f3badf changed.调整了开发者选项 2026-04-13 13:14:58 +08:00
lincube
76d13ac024 feat.开发者调试工具 2026-04-13 08:02:47 +08:00
lincube
99a82d64e3 change.插件设置支持View 2026-04-13 01:23:11 +08:00
lincube
692ca3de3d Update CHANGELOG.md 2026-04-12 20:20:15 +08:00
lincube
d62226ffa0 fix. 试验性的修复了轻量版的Dotnet问题 2026-04-12 17:28:33 +08:00
lincube
91ab52ce8b change.插件sdk更新 2026-04-12 13:52:52 +08:00
lincube
4a89c2388b feat.便签组件 2026-04-12 12:14:25 +08:00
lincube
cb96180118 feat.白板笔色自适应主题 2026-04-12 01:10:12 +08:00
lincube
cf4b8e2132 fix.央广网新闻组件第二行显示修复,课程表显示修复。 2026-04-11 03:43:41 +08:00
lincube
e8ba847328 fix.我又改了一下融合桌面的设置窗口。 2026-04-11 00:35:27 +08:00
lincube
2156922039 feat.试验性地改了一下融合桌面的组件库,反正还是不能用。 2026-04-10 22:13:53 +08:00
lincube
e795e9964e feat.增加了无.net10的安装包版本,实验性的修改了融合桌面设置下的组件库样式。 2026-04-10 12:20:05 +08:00
lincube
11130cfdb3 feat.更新界面多标题修复。支持了,应用启动台不显示应用卡片背景。。。 2026-04-09 19:15:06 +08:00
lincube
66ae0b0270 fix.课表组件日间模式字体颜色修复 2026-04-09 00:53:28 +08:00
lincube
a671db8b69 更新 README.md 2026-04-08 23:32:39 +08:00
lincube
8c94253f92 fix.快捷方式组件的透明问题修复。顺便修了一下电源菜单。 2026-04-08 17:39:19 +08:00
lincube
6849a467d6 fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能 2026-04-08 16:22:32 +08:00
lincube
e69bbf8b19 feat.加入快捷方式组件 2026-04-08 02:09:17 +08:00
lincube
d30af21317 docs.加入changelog 2026-04-08 01:45:26 +08:00
lincube
8583465a67 fead.圆角,终于统一 2026-04-08 00:55:10 +08:00
lincube
e1d5a0c6de fead.添加了电源菜单 2026-04-07 12:18:15 +08:00
lincube
5fa2031ad6 fead.消息盒子组件 2026-04-07 00:49:33 +08:00
lincube
0662565dca fead.为文件管理组件添加了跨平台的支持 2026-04-05 14:02:07 +08:00
lincube
12a2f6729b fead.文件管理组件加入 2026-04-04 03:28:51 +08:00
lincube
5d2449fa8f fead.加入jiangtokoto数据源 2026-04-04 02:13:26 +08:00
lincube
00339f0ed0 fix.修Rinshub,怎么不是色色就是逆天 2026-04-03 22:55:35 +08:00
lincube
021c7ff245 fix.还是在修智教Hub组件 2026-04-03 22:07:38 +08:00
lincube
675096b6c4 fead.做了状态栏加了更多的胶囊组件。然后我稍微修了一下智教Hub组件 2026-04-03 21:25:15 +08:00
lincube
1c3cc76f21 fead.做了状态栏文字组件,支持了位置放置。 2026-04-03 13:14:20 +08:00
lincube
44b87ba12e fead.桌面组件 2026-04-03 11:42:00 +08:00
lincube
35976c3f3d fead.做桌面组件ing,智教hub加了rinshub 2026-04-03 01:17:47 +08:00
lincube
88bd92e40a fead.Hub组件支持双击打开图片,支持三指翻页退出应用 2026-04-02 21:12:06 +08:00
275 changed files with 55820 additions and 2240 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,9 @@ jobs:
sudo apt-get install -y \
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
- 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
@@ -66,9 +67,15 @@ jobs:
strategy:
fail-fast: false
matrix:
arch: [x64, x86]
name: Build_Windows_${{ matrix.arch }}
include:
- arch: x64
self_contained: true
suffix: ''
- arch: x86
self_contained: true
suffix: ''
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -93,23 +100,123 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch"
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
-o ./$launcherPublishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:SelfContained=true `
-r win-$arch `
-p:PublishAot=true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
-p:DebugSymbols=false
if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed"
exit 1
}
# 显示发布结果
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
$size = [Math]::Round($exeFile.Length / 1MB, 2)
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files"
$files | ForEach-Object { Write-Host " - $($_.Name)" }
}
shell: pwsh
- name: Publish Main App
run: |
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
if ($selfContained) {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} else {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
Write-Host "Published to: $publishDir"
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
@@ -120,27 +227,25 @@ jobs:
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish\windows-$arch"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$suffix = "${{ matrix.suffix }}"
$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) {
@@ -172,12 +277,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
@@ -187,33 +291,234 @@ jobs:
"/DPublishDir=$publishDir",
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
"/DMyAppSuffix=$suffix",
"/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:
name: release-windows-${{ matrix.arch }}
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
path: build-installer/*.exe
if-no-files-found: error
retention-days: 30
@@ -222,7 +527,7 @@ jobs:
needs: prepare
runs-on: ubuntu-latest
name: Build_Linux
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -238,7 +543,8 @@ jobs:
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -256,11 +562,35 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
-o ./publish/linux-x64-app \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
@@ -274,6 +604,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 }}"
@@ -283,28 +641,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
@@ -317,7 +671,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"
@@ -334,8 +688,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"
@@ -344,15 +697,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"
@@ -376,7 +727,7 @@ jobs:
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -401,11 +752,35 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }} \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
@@ -419,45 +794,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>'
@@ -471,11 +858,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"
@@ -483,8 +869,7 @@ jobs:
echo "Error: Failed to create DMG"
exit 1
fi
# Cleanup
rm -rf dmg-temp "${app_name}.app"
- name: Upload
@@ -500,7 +885,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -510,29 +895,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
@@ -548,19 +936,28 @@ jobs:
artifacts: "release-files/**"
body: |
## Release ${{ needs.prepare.outputs.version }}
### Windows
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (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,805 @@
# LanMountainDesktop Launcher 全面改进计划
## 概述
本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。
## 目标
1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合
2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序
3. **P2 (推荐完成)**: 实现 Launcher 自更新机制
4. **P3 (可选优化)**: 性能优化和代码清理
5. **P4 (长期规划)**: 增强功能和可扩展性
## 当前问题
1. Launcher 是 Avalonia 应用,启动慢、内存占用高
2. Launcher 引用了 PluginSdk与主程序有耦合
3. 主程序引用了 Launcher构建关系复杂
4. Launcher 职责过多OOBE + Splash + 更新 + 插件 + 启动)
5. 缺少 Launcher 自更新机制
6. GitHub Actions 工作流需要适配新的目录结构
## 改进后架构
```
安装根目录/
├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe ← 主程序
│ └── ... (所有依赖)
└── .launcher/ ← 启动器数据(可选)
└── snapshots/ ← 版本快照
```
## 详细实施步骤
### P0: 基础架构重构
#### 1. 重写 Launcher 为极简模式
**文件**: `LanMountainDesktop.Launcher/Program.cs`
**目标**:
- 代码量控制在 100 行以内
- 零外部依赖(不使用 Avalonia
- 只负责:版本选择、启动主程序、清理旧版本
**完整实现代码**:
```csharp
// LanMountainDesktop.Launcher/Program.cs
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Launcher;
internal static class Program
{
private const string HostExecutableName = "LanMountainDesktop.exe";
private const string HostExecutableNameLinux = "LanMountainDesktop";
[STAThread]
private static int Main(string[] args)
{
var rootDir = GetRootDirectory();
// 1. 查找最佳版本
var installation = FindBestVersion(rootDir);
if (installation == null)
{
ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。");
return 1;
}
// 2. 清理旧版本(异步,不阻塞)
_ = Task.Run(() => CleanupOldVersions(rootDir));
// 3. 启动主程序
return LaunchHost(installation, args);
}
private static string GetRootDirectory()
{
return Path.GetFullPath(
Path.GetDirectoryName(Environment.ProcessPath) ?? "");
}
private static string? FindBestVersion(string rootDir)
{
var exeName = OperatingSystem.IsWindows()
? HostExecutableName
: HostExecutableNameLinux;
return Directory.GetDirectories(rootDir)
.Where(x => IsValidVersionDirectory(x, exeName))
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1)
.ThenByDescending(x => ParseVersion(Path.GetFileName(x)))
.FirstOrDefault();
}
private static bool IsValidVersionDirectory(string path, string exeName)
{
var dirName = Path.GetFileName(path);
return dirName.StartsWith("app-") &&
!File.Exists(Path.Combine(path, ".destroy")) &&
!File.Exists(Path.Combine(path, ".partial")) &&
File.Exists(Path.Combine(path, exeName));
}
private static Version ParseVersion(string dirName)
{
// app-1.0.0 or app-1.0.0-123
var parts = dirName.Split('-');
if (parts.Length >= 2 && Version.TryParse(parts[1], out var v))
return v;
return new Version(0, 0);
}
private static void CleanupOldVersions(string rootDir)
{
try
{
var oldVersions = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in oldVersions)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
catch { /* 忽略清理失败 */ }
}
private static int LaunchHost(string installation, string[] args)
{
var exeName = OperatingSystem.IsWindows()
? HostExecutableName
: HostExecutableNameLinux;
var exePath = Path.Combine(installation, exeName);
// Linux/macOS: 确保可执行权限
if (!OperatingSystem.IsWindows())
{
EnsureExecutable(exePath);
}
var startInfo = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = Path.GetDirectoryName(installation),
UseShellExecute = true
};
foreach (var arg in args)
startInfo.ArgumentList.Add(arg);
// 传递环境变量
startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] =
Path.GetDirectoryName(installation);
startInfo.EnvironmentVariables["LMD_VERSION"] =
Path.GetFileName(installation).Replace("app-", "");
try
{
Process.Start(startInfo);
return 0;
}
catch (Exception ex)
{
ShowError($"启动失败: {ex.Message}");
return 1;
}
}
private static void EnsureExecutable(string path)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "chmod",
Arguments = $"+x \"{path}\"",
CreateNoWindow = true
})?.WaitForExit();
}
catch { }
}
private static void ShowError(string message)
{
if (OperatingSystem.IsWindows())
{
// Win32 MessageBox
try
{
MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10);
}
catch
{
Console.Error.WriteLine(message);
}
}
else
{
Console.Error.WriteLine(message);
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
}
```
#### 2. 修改 Launcher 项目文件
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
**完整内容**:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<!-- 图标资源 -->
<ItemGroup>
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
```
#### 3. 移除主程序对 Launcher 的引用
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
**修改**: 删除以下行
```xml
<!-- 删除这一行 -->
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
```
#### 4. 修改主程序支持新架构
**文件**: `LanMountainDesktop/Program.cs`
**修改**: 添加环境变量读取
```csharp
// 在 Program.cs 中添加
internal static class LaunchContext
{
public static string? PackageRoot =>
Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT");
public static string? Version =>
Environment.GetEnvironmentVariable("LMD_VERSION");
public static bool IsLaunchedByLauncher =>
!string.IsNullOrEmpty(PackageRoot);
}
```
---
### P1: 功能迁移
#### 5. 将 OOBE 迁移到主程序
**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs`
```csharp
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services.Oobe;
public class OobeService
{
private readonly string _oobeStatePath;
public OobeService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
_oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed");
}
public bool IsFirstRun()
{
return !File.Exists(_oobeStatePath);
}
public void MarkCompleted()
{
var dir = Path.GetDirectoryName(_oobeStatePath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O"));
}
}
```
**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Oobe.OobeWindow"
Title="欢迎使用阑山桌面"
Width="800"
Height="600"
WindowStartupLocation="CenterScreen">
<Grid>
<!-- OOBE 界面内容 -->
<TextBlock Text="欢迎使用阑山桌面" FontSize="24" HorizontalAlignment="Center" Margin="0,50,0,0"/>
<Button Content="开始使用" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="OnStartClick"/>
</Grid>
</Window>
```
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
// 在 OnFrameworkInitializationCompleted 中添加
private async Task InitializeOobeAsync()
{
var oobeService = new OobeService();
if (oobeService.IsFirstRun())
{
var oobeWindow = new Views.Oobe.OobeWindow();
await oobeWindow.ShowDialog();
oobeService.MarkCompleted();
}
}
```
#### 6. 将 Splash 迁移到主程序
**新建文件**: `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Splash.SplashWindow"
Title="阑山桌面"
Width="400"
Height="300"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="False"
SystemDecorations="None">
<Grid Background="{DynamicResource SystemAccentColor}">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<Image Source="/Assets/logo_nightly.png" Width="100" Height="100"/>
<TextBlock Text="阑山桌面" FontSize="20" Margin="0,20,0,0" HorizontalAlignment="Center"/>
<TextBlock x:Name="StatusText" Text="正在启动..." Margin="0,10,0,0" HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Window>
```
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
// 在初始化时显示 Splash
private SplashWindow? _splashWindow;
private void ShowSplash()
{
_splashWindow = new SplashWindow();
_splashWindow.Show();
}
private void CloseSplash()
{
_splashWindow?.Close();
_splashWindow = null;
}
```
#### 7. 将更新逻辑迁移到主程序
**新建目录**: `LanMountainDesktop/Services/Update/`
**新建文件**: `LanMountainDesktop/Services/Update/UpdateService.cs`
```csharp
using System.Net.Http.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services.Update;
public class UpdateService
{
private readonly HttpClient _httpClient;
private readonly string _currentVersion;
public UpdateService()
{
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop");
_currentVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
}
public async Task<UpdateCheckResult> CheckForUpdateAsync(UpdateChannel channel)
{
// 调用 GitHub Release API
var releases = await _httpClient.GetFromJsonAsync<List<GitHubRelease>>(
"https://api.github.com/repos/ClassIsland/LanMountainDesktop/releases");
var latest = channel == UpdateChannel.Stable
? releases?.FirstOrDefault(r => !r.Prerelease)
: releases?.FirstOrDefault();
if (latest == null)
return new UpdateCheckResult { HasUpdate = false };
var latestVersion = latest.TagName.TrimStart('v');
var hasUpdate = new Version(latestVersion) > new Version(_currentVersion);
return new UpdateCheckResult
{
HasUpdate = hasUpdate,
Version = latestVersion,
DownloadUrl = latest.Assets.FirstOrDefault()?.BrowserDownloadUrl
};
}
}
public class UpdateCheckResult
{
public bool HasUpdate { get; set; }
public string? Version { get; set; }
public string? DownloadUrl { get; set; }
}
public enum UpdateChannel { Stable, Preview }
public class GitHubRelease
{
public string TagName { get; set; } = "";
public bool Prerelease { get; set; }
public List<GitHubAsset> Assets { get; set; } = new();
}
public class GitHubAsset
{
public string BrowserDownloadUrl { get; set; } = "";
}
```
#### 8. 将插件管理迁移到主程序
**新建目录**: `LanMountainDesktop/Services/Plugins/`
**新建文件**: `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
```csharp
namespace LanMountainDesktop.Services.Plugins;
public class PluginUpdateService
{
private readonly string _pluginsDirectory;
public PluginUpdateService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
_pluginsDirectory = Path.Combine(appData, "LanMountainDesktop", "plugins");
}
public async Task CheckAndUpdatePluginsAsync()
{
// 检查插件更新
// 下载并安装更新
}
}
```
---
### P2: 自更新机制
#### 9. 实现 Launcher 自更新
**修改文件**: `LanMountainDesktop.Launcher/Program.cs`
```csharp
// 在 Main 方法开头添加自更新检查
private static void CheckForLauncherUpdate()
{
var rootDir = GetRootDirectory();
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
if (File.Exists(updatePath))
{
// 有新版本 Launcher替换自身
try
{
var currentPath = Environment.ProcessPath;
var backupPath = currentPath + ".old";
// 重命名当前版本
if (File.Exists(backupPath))
File.Delete(backupPath);
File.Move(currentPath!, backupPath);
// 移动新版本
File.Move(updatePath, currentPath!);
// 删除备份
File.Delete(backupPath);
// 重启自己
Process.Start(new ProcessStartInfo
{
FileName = currentPath,
UseShellExecute = true
});
Environment.Exit(0);
}
catch (Exception ex)
{
// 回滚
Console.Error.WriteLine($"Launcher 更新失败: {ex.Message}");
}
}
}
```
#### 10. 主程序支持更新 Launcher
**新建文件**: `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
```csharp
namespace LanMountainDesktop.Services.Update;
public class LauncherUpdateService
{
private readonly HttpClient _httpClient;
public LauncherUpdateService()
{
_httpClient = new HttpClient();
}
public async Task<bool> UpdateLauncherAsync(string downloadUrl)
{
var rootDir = LaunchContext.PackageRoot
?? Path.GetDirectoryName(Environment.ProcessPath)!;
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
// 下载新版本
var response = await _httpClient.GetAsync(downloadUrl);
await using var fs = File.Create(updatePath);
await response.Content.CopyToAsync(fs);
return true;
}
public void RestartWithNewLauncher()
{
var launcherPath = Path.Combine(
LaunchContext.PackageRoot ?? "",
"LanMountainDesktop.exe");
Process.Start(new ProcessStartInfo
{
FileName = launcherPath,
UseShellExecute = true
});
// 退出主程序,让 Launcher 接管
Environment.Exit(0);
}
}
```
---
### P3: 清理旧代码
#### 11. 删除文件清单
**删除以下文件/目录**:
```
LanMountainDesktop.Launcher/
├── App.axaml ← 删除
├── App.axaml.cs ← 删除
├── Views/ ← 删除整个目录
│ ├── OobeWindow.axaml
│ ├── OobeWindow.axaml.cs
│ ├── SplashWindow.axaml
│ └── SplashWindow.axaml.cs
├── Services/ ← 删除大部分
│ ├── LauncherFlowCoordinator.cs ← 删除
│ ├── OobeStateService.cs ← 删除
│ ├── UpdateCheckService.cs ← 删除
│ ├── UpdateEngineService.cs ← 删除
│ ├── PluginInstallerService.cs ← 删除
│ └── PluginUpgradeQueueService.cs ← 删除
└── Models/ ← 删除(如不再需要)
```
---
### P4: GitHub Actions 工作流修改
#### 12. 修改 release.yml
**关键修改点**:
1. **Launcher 单独编译**:
```yaml
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/launcher-win-x64 `
--self-contained `
-r win-x64 `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:DebugType=none
```
2. **目录结构调整**:
```yaml
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-x64"
$launcherDir = "publish/launcher-win-x64"
$appDir = "app-$version"
# 创建新结构
$newStructure = "publish-launcher/windows-x64"
New-Item -ItemType Directory -Path $newStructure -Force
# 移动主程序到 app-{version}/
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
# 复制 Launcher 到根目录
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
# 创建 .current 标记
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
```
3. **Linux/macOS 同样调整**:
- Linux: 修改 DEB 打包流程
- macOS: 修改 DMG 打包流程
#### 13. 修改 build.yml
**修改**: 移除 Launcher 相关构建步骤,因为 Launcher 现在完全独立
---
### P5: 图标资源处理
#### 14. Launcher 图标配置
**方案**: 使用链接方式引用主程序图标
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
```xml
<ItemGroup>
<!-- 链接主程序的图标 -->
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
#### 15. 安装程序配置
**文件**: `LanMountainDesktop/installer/LanMountainDesktop.iss` (Inno Setup)
**关键配置**:
```ini
[Setup]
AppName=阑山桌面
AppVersion={#MyAppVersion}
DefaultDirName={autopf}\LanMountainDesktop
OutputBaseFilename=LanMountainDesktop-Setup-{#MyAppVersion}-x64
SetupIconFile=..\Assets\logo_nightly.ico
UninstallDisplayIcon={app}\LanMountainDesktop.exe
[Files]
; Launcher
Source: "..\..\publish\windows-x64\LanMountainDesktop.exe"; DestDir: "{app}"; Flags: ignoreversion
; 主程序版本目录
Source: "..\..\publish\windows-x64\app-{#MyAppVersion}\*"; DestDir: "{app}\app-{#MyAppVersion}"; Flags: ignoreversion recursesubdirs
[Icons]
; 桌面快捷方式
Name: "{autodesktop}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
; 开始菜单
Name: "{group}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
```
---
## 文件变更清单
### 修改文件
1. `LanMountainDesktop.Launcher/Program.cs` - 完全重写
2. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 简化依赖
3. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
4. `LanMountainDesktop/Program.cs` - 添加 LaunchContext
5. `LanMountainDesktop/App.axaml.cs` - 添加 OOBE/Splash/更新入口
6. `.github/workflows/release.yml` - 调整打包流程
7. `.github/workflows/build.yml` - 适配新构建流程
### 新增文件
1. `LanMountainDesktop/Services/Oobe/OobeService.cs`
2. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
3. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml.cs`
4. `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
5. `LanMountainDesktop/Views/Splash/SplashWindow.axaml.cs`
6. `LanMountainDesktop/Services/Update/UpdateService.cs`
7. `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
8. `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
### 删除文件
1. `LanMountainDesktop.Launcher/App.axaml`
2. `LanMountainDesktop.Launcher/App.axaml.cs`
3. `LanMountainDesktop.Launcher/Views/` 目录
4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
5. `LanMountainDesktop.Launcher/Services/OobeStateService.cs`
6. `LanMountainDesktop.Launcher/Services/UpdateCheckService.cs`
7. `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
8. `LanMountainDesktop.Launcher/Services/PluginInstallerService.cs`
9. `LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs`
---
## 风险与回滚方案
### 风险
1. **启动失败**: 新 Launcher 可能有 bug 导致无法启动
2. **更新中断**: 更新逻辑迁移可能导致更新失败
3. **图标丢失**: 图标配置错误导致快捷方式无图标
### 回滚方案
1. 保留原 Launcher 代码分支
2. 准备紧急修复版本
3. 用户可手动下载完整安装包恢复
---
## 验证清单
- [ ] Launcher 能正常启动主程序
- [ ] 版本选择逻辑正确
- [ ] 旧版本清理正常
- [ ] OOBE 流程正常
- [ ] Splash 显示正常
- [ ] 更新检查正常
- [ ] 插件安装正常
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常
---
## 实施顺序建议
### 第一阶段(立即实施)
1. 重写 Launcher Program.cs
2. 修改 Launcher.csproj
3. 移除主程序对 Launcher 的引用
4. 测试基本启动功能
### 第二阶段(功能迁移)
1. 迁移 OOBE 到主程序
2. 迁移 Splash 到主程序
3. 迁移更新逻辑到主程序
4. 迁移插件管理到主程序
### 第三阶段CI/CD
1. 修改 release.yml
2. 修改 build.yml
3. 测试打包流程
4. 验证安装程序
### 第四阶段(优化)
1. 实现 Launcher 自更新
2. 性能优化
3. 清理旧代码

View File

@@ -0,0 +1,717 @@
# LanMountainDesktop Launcher 改进计划 V2
## 核心设计理念
**Launcher 是核心协调器,不是极简启动器**
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Launcher 职责定位 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Launcher 负责(启动前 & 退出后): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • OOBE 首次引导 │ │
│ │ • 启动动画 (Splash) │ │
│ │ • 插件安装 │ │
│ │ • 插件更新 │ │
│ │ • 应用增量更新安装(不是下载!) │ │
│ │ • 应用静默更新安装 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 主程序负责(运行时): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • 多线程下载(有完整 Downloader │ │
│ │ • 更新渠道切换 │ │
│ │ • 下载管理 │ │
│ │ • 与 Launcher 通讯(启动进度) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 关键优势: │
│ • Launcher 在应用启动前运行 → 可以安装更新而不担心文件占用 │
│ • Launcher 在应用退出后运行 → 可以完成待处理的安装任务 │
│ • 主程序专注下载 → 利用完整的多线程下载器提高效率 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 为什么保留 Avalonia
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 保留 Avalonia 的理由 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 启动画面 (Splash) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 需要显示启动进度 │ │
│ │ • 需要显示品牌 Logo │ │
│ │ • 需要流畅的动画效果 │ │
│ │ • 纯 Win32 实现复杂且不易维护 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. OOBE 首次引导 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 需要多步骤向导界面 │ │
│ │ • 需要丰富的交互控件 │ │
│ │ • 需要与主程序一致的视觉风格 │ │
│ │ • Avalonia 提供完整的 UI 框架 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. 与主程序的技术栈一致 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 共享主题和资源 │ │
│ │ • 共享控件和样式 │ │
│ │ • 便于维护和迭代 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 改进后的架构设计
### 目录结构(保持不变)
```
安装根目录/
├── LanMountainDesktop.exe ← LauncherAvalonia 应用)
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe ← 主程序
│ └── ... (所有依赖)
└── .launcher/ ← Launcher 数据目录
├── update/ ← 更新缓存
│ └── incoming/ ← 下载的更新包(主程序下载到这里)
└── snapshots/ ← 版本快照
```
### 核心流程设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 启动流程(含通讯机制) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户启动 LanMountainDesktop.exe (Launcher) │
│ ↓ │
│ 2. Launcher 检查是否有待处理的更新安装 │
│ ↓ │
│ 3. 有更新──Yes──▶ 显示 Splash "正在安装更新..." │
│ ↓ ↓ │
│ No 安装更新(增量/静默) │
│ ↓ ↓ │
│ 4. 检查是否首次运行 ──Yes──▶ 显示 OOBE 窗口 │
│ ↓ No ↓ │
│ 5. 显示 Splash "正在启动..." 完成 OOBE │
│ ↓ │
│ 6. 启动主程序进程(带通讯参数) │
│ ↓ │
│ 7. Launcher 保持运行,监听主程序进度 ─────── IPC 通讯 ───────▶ 主程序 │
│ ↓ │
│ 8. 主程序报告启动进度 ─────── IPC 通讯 ───────▶ Launcher 更新 Splash │
│ ↓ │
│ 9. 主程序完全启动 ──Yes──▶ Launcher 关闭 Splash进入后台/退出 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 退出流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 退出流程(处理待安装任务) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 主程序准备退出 │
│ ↓ │
│ 2. 检查是否有待安装的更新/插件 ──Yes──▶ 重启 Launcher 并传递参数 │
│ ↓ No ↓ │
│ 3. 正常退出 Launcher 在应用退出后运行 │
│ ↓ │
│ 安装待处理的任务 │
│ ↓ │
│ 完成后再次启动主程序 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Launcher 与主程序的通讯机制
### IPC 方案选择
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ IPC 通讯方案 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 方案 1: 命令行参数 + 退出码(推荐用于启动阶段) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 启动主程序: │ │
│ │ LanMountainDesktop.exe --launcher-pid 12345 --ipc-port 50000 │ │
│ │ │ │
│ │ 主程序通过命名管道/HTTP 与 Launcher 通讯 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 方案 2: 命名管道(推荐用于进度报告) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ 主程序连接并发送进度消息 │ │
│ │ │ │
│ │ 消息格式: JSON │ │
│ │ {"stage": "initializing", "progress": 30, "message": "加载设置..."} │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 方案 3: 共享内存/文件(简单状态同步) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 和主程序读写同一个状态文件 │ │
│ │ .launcher/state/startup_status.json │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 通讯协议设计
```csharp
// 共享契约LanMountainDesktop.Shared.Contracts
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum StartupStage
{
Initializing,
LoadingSettings,
LoadingPlugins,
InitializingUI,
Ready
}
public record StartupProgressMessage
{
public StartupStage Stage { get; init; }
public int ProgressPercent { get; init; } // 0-100
public string? Message { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public static class LauncherIpc
{
public const string PipeName = "LanMountainDesktop_Launcher";
public const string EnvironmentVariablePrefix = "LMD_";
}
```
## 详细实施步骤
### P0: 架构调整(核心)
#### 1. 调整 Launcher 项目引用
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
**修改**:
- 保留 Avalonia 依赖
- 移除 PluginSdk 引用Launcher 不需要)
- 添加 Shared.Contracts 引用(用于 IPC
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Assetsogo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 保留 Avalonia -->
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<!-- 图标资源 -->
<ItemGroup>
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
```
#### 2. 移除主程序对 Launcher 的引用
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
**修改**: 删除 Launcher 引用
```xml
<!-- 删除 -->
<!-- <ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" /> -->
```
#### 3. 创建 IPC 通讯契约
**新建文件**: `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs`
```csharp
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum StartupStage
{
Initializing,
LoadingSettings,
LoadingPlugins,
InitializingUI,
Ready
}
public record StartupProgressMessage
{
public StartupStage Stage { get; init; }
public int ProgressPercent { get; init; }
public string? Message { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public static class LauncherIpcConstants
{
public const string PipeName = "LanMountainDesktop_Launcher";
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
public const string VersionEnvVar = "LMD_VERSION";
}
```
### P1: Launcher 端实现
#### 4. 实现 IPC 服务端
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
```csharp
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipeServer;
private readonly Action<StartupProgressMessage> _onProgress;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
public async Task StartAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipeServer = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Message);
await _pipeServer.WaitForConnectionAsync(_cts.Token);
using var reader = new StreamReader(_pipeServer);
var json = await reader.ReadToEndAsync(_cts.Token);
if (!string.IsNullOrEmpty(json))
{
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
if (message != null)
{
_onProgress(message);
}
}
_pipeServer.Disconnect();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC error: {ex.Message}");
}
}
}
public void Dispose()
{
_cts.Cancel();
_pipeServer?.Dispose();
_cts.Dispose();
}
}
```
#### 5. 修改 Launcher 启动流程
**修改文件**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
```csharp
public async Task<LauncherResult> RunAsync()
{
// 1. 清理旧版本
_deploymentLocator.CleanupDestroyedDeployments();
// 2. 检查并安装待处理的更新(主程序下载的)
var pendingUpdate = _updateEngine.CheckPendingUpdate();
if (pendingUpdate.HasUpdate)
{
_splashWindow?.UpdateStatus("正在安装更新...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
if (!updateResult.Success)
{
return updateResult;
}
}
// 3. 检查并安装待处理的插件更新
var pendingPlugins = _pluginUpgradeQueueService.CheckPendingUpgrades();
if (pendingPlugins.HasUpgrades)
{
_splashWindow?.UpdateStatus("正在更新插件...");
var pluginResult = _pluginUpgradeQueueService.ApplyPendingUpgrades();
if (!pluginResult.Success)
{
return pluginResult;
}
}
// 4. OOBE
if (_oobeStateService.IsFirstRun())
{
_splashWindow?.Hide();
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None);
}
_splashWindow?.Show();
}
// 5. 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
_splashWindow?.UpdateProgress(msg.ProgressPercent, msg.Message);
});
_ = ipcServer.StartAsync();
// 6. 启动主程序
_splashWindow?.UpdateStatus("正在启动...");
var hostResult = LaunchHostWithIpc();
if (!hostResult.Success)
{
return hostResult;
}
// 7. 等待主程序报告就绪或超时
await WaitForHostReadyOrTimeoutAsync(TimeSpan.FromSeconds(30));
return new LauncherResult { Success = true };
}
```
### P2: 主程序端实现
#### 6. 实现 IPC 客户端
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
```csharp
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Launcher;
public class LauncherIpcClient : IDisposable
{
private NamedPipeClientStream? _pipeClient;
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
_pipeClient = new NamedPipeClientStream(
".",
LauncherIpcConstants.PipeName,
PipeDirection.Out);
await _pipeClient.ConnectAsync(5000, cancellationToken);
}
public async Task ReportProgressAsync(StartupProgressMessage message)
{
if (_pipeClient?.IsConnected != true)
return;
var json = JsonSerializer.Serialize(message);
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
await writer.WriteAsync(json);
await writer.FlushAsync();
}
public void Dispose()
{
_pipeClient?.Dispose();
}
}
```
#### 7. 主程序启动时报告进度
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
public override async void OnFrameworkInitializationCompleted()
{
// 检查是否从 Launcher 启动
var launcherPid = Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar);
if (!string.IsNullOrEmpty(launcherPid))
{
// 连接到 Launcher 的 IPC 服务端
_launcherIpc = new LauncherIpcClient();
await _launcherIpc.ConnectAsync();
// 报告启动进度
await _launcherIpc.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Initializing,
ProgressPercent = 10,
Message = "正在初始化..."
});
}
// 初始化设置
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.LoadingSettings,
ProgressPercent = 30,
Message = "正在加载设置..."
});
InitializeSettings();
// 加载插件
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.LoadingPlugins,
ProgressPercent = 50,
Message = "正在加载插件..."
});
await InitializePluginsAsync();
// 初始化 UI
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.InitializingUI,
ProgressPercent = 80,
Message = "正在初始化界面..."
});
InitializeUI();
// 就绪
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
base.OnFrameworkInitializationCompleted();
}
```
### P3: 更新流程整合
#### 8. 主程序下载更新
**主程序职责**:
```csharp
// 主程序中的更新服务
public class AppUpdateService
{
public async Task DownloadUpdateAsync(string version, string downloadUrl)
{
// 使用多线程下载器下载更新包
var downloader = new MultiThreadedDownloader();
var targetPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
".launcher",
"update",
"incoming",
$"update-{version}.zip");
await downloader.DownloadAsync(downloadUrl, targetPath);
// 标记为待安装
File.WriteAllText(
Path.Combine(Path.GetDirectoryName(targetPath)!, ".pending"),
version);
}
}
```
#### 9. Launcher 安装更新
**Launcher 职责**:
```csharp
// Launcher 中的更新安装服务
public class UpdateInstallationService
{
public async Task<InstallResult> InstallPendingUpdateAsync()
{
var pendingPath = Path.Combine(
_appRoot,
".launcher",
"update",
"incoming",
".pending");
if (!File.Exists(pendingPath))
return InstallResult.NoUpdate;
var version = File.ReadAllText(pendingPath);
var updatePackagePath = Path.Combine(
Path.GetDirectoryName(pendingPath)!,
$"update-{version}.zip");
// 创建新版本目录
var newVersionDir = Path.Combine(_appRoot, $"app-{version}");
Directory.CreateDirectory(newVersionDir);
File.WriteAllText(Path.Combine(newVersionDir, ".partial"), "");
// 解压更新包
ZipFile.ExtractToDirectory(updatePackagePath, newVersionDir);
// 验证文件完整性
// ...
// 切换版本标记
var currentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (currentDir != null)
{
File.Delete(Path.Combine(currentDir, ".current"));
File.WriteAllText(Path.Combine(currentDir, ".destroy"), "");
}
File.WriteAllText(Path.Combine(newVersionDir, ".current"), "");
File.Delete(Path.Combine(newVersionDir, ".partial"));
// 清理待安装标记
File.Delete(pendingPath);
File.Delete(updatePackagePath);
return InstallResult.Success;
}
}
```
### P4: GitHub Actions 工作流
#### 10. 修改 release.yml
**关键修改点**:
```yaml
# 1. Launcher 单独编译(保留 Avalonia
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/launcher-win-x64 `
--self-contained `
-r win-x64 `
-p:PublishSingleFile=false `
-p:DebugType=none
# 2. 目录结构调整
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-x64"
$launcherDir = "publish/launcher-win-x64"
$appDir = "app-$version"
# 创建新结构
$newStructure = "publish-launcher/windows-x64"
New-Item -ItemType Directory -Path $newStructure -Force
# 移动主程序到 app-{version}/
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
# 复制 Launcher 到根目录
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
# 创建 .current 标记
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
```
## 文件变更清单
### 修改文件
1. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 调整引用
2. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` - 添加 IPC 和更新安装
4. `LanMountainDesktop/App.axaml.cs` - 添加 IPC 客户端和进度报告
5. `.github/workflows/release.yml` - 调整打包流程
### 新增文件
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
### 删除文件
1. 主程序对 Launcher 的项目引用(已存在)
## 实施顺序
### 第一阶段:基础架构
1. 创建 IPC 契约Shared.Contracts
2. 调整 Launcher 项目引用
3. 移除主程序对 Launcher 的引用
4. 测试基本启动
### 第二阶段IPC 实现
1. 实现 Launcher IPC 服务端
2. 实现主程序 IPC 客户端
3. 测试进度报告
### 第三阶段:更新流程
1. 主程序实现下载功能
2. Launcher 实现安装功能
3. 测试完整更新流程
### 第四阶段CI/CD
1. 修改 GitHub Actions
2. 测试打包流程
3. 验证安装程序
## 验证清单
- [ ] Launcher 能正常启动主程序
- [ ] Launcher 显示 Splash 并接收进度更新
- [ ] 主程序能向 Launcher 报告启动进度
- [ ] 主程序能下载更新
- [ ] Launcher 能安装待处理的更新
- [ ] OOBE 流程正常
- [ ] 插件更新流程正常
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常

View File

@@ -0,0 +1,112 @@
---
name: "refactoring-insight"
description: "Analyzes codebase for refactoring opportunities: large files, code duplication, god classes, naming inconsistencies, tight coupling, and missing abstractions. Invoke when user asks for refactoring insight/analysis or wants to improve code architecture."
---
# Refactoring Insight
Deep codebase analysis skill that identifies structural problems and produces prioritized refactoring recommendations.
## When to Invoke
- User asks for "refactoring insight", "refactoring analysis", "code quality analysis", "architecture review"
- User wants to understand what should be refactored in the codebase
- User asks "where are the code smells?" or "what needs refactoring?"
## Analysis Dimensions
Run all 6 dimensions in parallel where possible. For each dimension, use search agents to gather data, then synthesize findings.
### 1. Large Files / God Classes
- Find all .cs files over 300 lines, sorted by line count descending
- Identify partial classes and sum their total line count across files
- Flag classes with 15+ methods or constructors taking 8+ parameters
- Focus on: Views/, ViewModels/, Services/, plugins/
**Output**: Table of files with line counts and responsibility summary.
### 2. Code Duplication
Search for these specific duplication patterns:
- **Service boilerplate**: Repeated DI registration, `new` instantiation instead of DI
- **Data service pattern**: Services that fetch/parse/transform data similarly (Load → Map → Save)
- **Localization pattern**: `private readonly LocalizationService _localizationService = new();` and `L()` helper method repetitions
- **Helper method duplication**: Methods like `ResolveUnifiedMainRadiusValue`, `NormalizeConfig`, `ParticleState` classes copied across files
- **Error handling pattern**: Identical try-catch blocks repeated in multiple methods
- **Settings snapshot pattern**: `_settingsFacade.Settings.LoadSnapshot<T>(scope)` call sites
**Output**: List of duplicated patterns with file locations and line numbers.
### 3. Tight Coupling
- Services instantiated via `new` instead of DI injection
- ViewModels directly accessing infrastructure-layer APIs (e.g., `LoadSnapshot/SaveSnapshot`)
- Hard-coded dependencies (GitHub repo owner/name, default values)
- `Application.Current` upcasting to access services: `(Application.Current as App)?.SomeService`
- Platform-specific code embedded in cross-platform services without interface abstraction
**Output**: Table of coupling violations with severity (high/medium/low).
### 4. Naming Inconsistencies
- Service suffix inconsistency: `Service` vs `Store` vs `Helper` vs `Provider` vs `Manager` vs `Factory` for similar responsibilities
- Model suffix inconsistency: `Snapshot` vs `State` vs `Types` for similar concepts
- Platform prefix inconsistency: `Windows`/`Linux` full name vs `Mac` abbreviation
- Confusing names: services with similar names but different responsibilities (e.g., `NotificationService` vs `NotificationListenerService`)
**Output**: Categorized list of naming inconsistencies.
### 5. Missing Abstractions
- Services without corresponding interfaces (check for `I<ServiceName>` pattern)
- Common patterns that could be extracted into base classes:
- `SettingsPageViewModelBase` for shared ViewModel boilerplate
- `JsonFileSettingsService<TSnapshot>` for repeated settings persistence
- `SettingsDomainServiceBase<TState>` for Load-Map-Save pattern
- `DesktopComponentWidgetBase` for shared Widget code
- `ComponentEditorViewBase` enhancements (e.g., `_suppressEvents` pattern)
- Static singleton/Factory providers repeating thread-safe lazy-load boilerplate
**Output**: List of missing abstractions with proposed base class/interface names.
### 6. Misplaced Responsibilities
- Files in wrong directories (e.g., data access in Settings/, UI services mixed with data services)
- ViewModels containing business logic or file system operations
- Widget code-behind files with excessive logic (>200 lines)
- Platform-specific services not organized into subdirectories
**Output**: List of misplaced files/classes with recommended new locations.
## Output Format
Produce a structured report with:
1. **Summary table**: Total metrics (file count, duplication count, etc.)
2. **Priority-ranked findings**: P0 (must fix), P1 (should fix), P2 (recommended), P3 (nice to have)
3. **Each finding includes**: Problem description, affected files with links, specific line numbers, recommended action, estimated impact
### Priority Criteria
- **P0**: Files over 1000 lines with mixed responsibilities; patterns duplicated 10+ times; god classes with 20+ dependencies
- **P1**: Patterns duplicated 5-9 times; services without interfaces that are widely used; DI bypass affecting testability
- **P2**: Patterns duplicated 3-4 times; naming inconsistencies affecting readability; misplaced files
- **P3**: Minor naming variations; single-instance duplications; organizational improvements
## Project-Specific Context
This skill is aware of the LanMountainDesktop project structure:
- `LanMountainDesktop/Services/` — Business and infrastructure services
- `LanMountainDesktop/Services/Settings/` — Settings subsystem
- `LanMountainDesktop/ViewModels/` — View models
- `LanMountainDesktop/Views/Components/` — Desktop widget components
- `LanMountainDesktop/Views/ComponentEditors/` — Widget editor views
- `LanMountainDesktop/plugins/` — Plugin runtime
- `LanMountainDesktop.PluginSdk/` — Plugin SDK public API
- `LanMountainDesktop.Shared.Contracts/` — Host/plugin shared contracts
- `LanMountainDesktop.Appearance/` — Appearance and corner radius infrastructure
When analyzing, respect the project's architectural boundaries documented in `docs/ARCHITECTURE.md` and `docs/ECOSYSTEM_BOUNDARIES.md`.

View File

@@ -0,0 +1,166 @@
# 融合桌面组件库窗口重设计规格
## Why
当前融合桌面组件库窗口FusedDesktopComponentLibraryWindow的UI设计较为基础与Windows 11小组件编辑面板相比缺乏现代化的交互体验和视觉层次。用户需要一个更直观、更美观的界面来浏览和添加组件到系统桌面负一屏
参考Windows 11小组件编辑面板的设计特点
* 左侧分类列表,右侧选中组件的详细预览
* 大型组件预览区域,让用户清楚看到组件效果
* 底部明显的"添加"操作按钮
* 简洁的关闭按钮X在右上角
* 深色主题配合毛玻璃效果
## What Changes
* **重新设计窗口布局**:从左右分栏(分类列表+组件网格)改为左侧面板+右侧预览区的布局
* **添加组件详情预览区**:选中组件后右侧显示大尺寸预览和组件信息
* **优化关闭按钮**使用标准的X图标按钮不使用圆形样式
* **添加底部操作栏**:包含"添加到桌面"主操作按钮和"查找更多组件"链接
* **复用阑山桌面组件库分类**使用相同的分类ID、图标和本地化文本
* **移除搜索功能**参考Windows 11设计暂不提供搜索
## Impact
* 受影响文件:
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
* `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(可能需要添加新属性)
## ADDED Requirements
### Requirement: 窗口布局重设计
系统应提供一个类似于Windows 11小组件编辑面板的组件库窗口。
#### Scenario: 窗口整体结构
* **GIVEN** 用户从托盘菜单打开融合桌面组件库
* **WHEN** 窗口显示时
* **THEN** 窗口应呈现:
* 顶部标题栏:左侧显示"添加小组件"标题右侧有关闭按钮X
* 左侧面板:分类列表(复用阑山桌面组件库的分类和图标)
* 右侧主区域:选中组件的大尺寸预览 + 组件信息 + 添加按钮
* 底部:"查找更多组件"链接
#### Scenario: 分类列表交互
* **GIVEN** 左侧显示组件分类列表
* **WHEN** 用户点击某个分类
* **THEN** 右侧应显示该分类下的第一个组件的预览
* **AND** 分类项应有选中状态视觉反馈
* **AND** 分类图标和名称应与阑山桌面组件库保持一致
#### Scenario: 组件预览区
* **GIVEN** 用户选中一个组件
* **WHEN** 预览区显示时
* **THEN** 应显示:
* 组件标题(大字号)
* 大尺寸组件预览图(接近实际尺寸)
* 组件描述/功能说明
* 底部"添加到桌面"按钮
#### Scenario: 添加组件操作
* **GIVEN** 用户查看组件预览
* **WHEN** 用户点击"添加到桌面"按钮
* **THEN** 组件应被添加到系统桌面(负一屏)中央
* **AND** 窗口应关闭
#### Scenario: 关闭按钮样式
* **GIVEN** 窗口标题栏有关闭按钮
* **THEN** 关闭按钮应使用标准的X图标
* **AND** 不使用圆形背景或特殊样式
* **AND** 使用 `DesignCornerRadiusSm` 动态资源
#### Scenario: 查找更多组件链接
* **GIVEN** 窗口底部显示"查找更多组件"链接
* **WHEN** 用户点击该链接
* **THEN** 应打开设置窗口的插件目录页面(后续将改为插件市场)
## MODIFIED Requirements
### Requirement: 组件列表展示
原实现使用网格展示所有组件,新实现改为:
* 左侧列表仅显示分类复用阑山桌面组件库的分类ID和图标映射
* 右侧预览区一次只显示一个组件的详细信息
* ~~移除搜索功能~~根据Windows 11设计暂不提供搜索
### Requirement: 关闭按钮圆角规范
原实现关闭按钮使用硬编码 `CornerRadius="18"`,应改为使用动态资源 `DesignCornerRadiusSm`
### Requirement: 分类图标复用
分类图标映射应与阑山桌面组件库保持一致:
* Clock -> Symbol.Clock
* Date -> Symbol.CalendarDate
* Weather -> Symbol.WeatherSunny
* Board -> Symbol.Edit
* Media -> Symbol.Play
* Info -> Symbol.Info
* Calculator -> Symbol.Calculator
* Study -> Symbol.Hourglass
* 其他 -> Symbol.Apps
## REMOVED Requirements
* ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能

View File

@@ -0,0 +1,35 @@
# Tasks
- [x] Task 1: 修改 FusedDesktopComponentLibraryWindow.axaml 窗口布局
- [x] SubTask 1.1: 重新设计标题栏使用标准X关闭按钮移除圆形样式使用 DesignCornerRadiusSm
- [x] SubTask 1.2: 调整窗口整体布局为左侧面板+右侧预览区
- [x] SubTask 1.3: 添加底部"查找更多组件"链接区域
- [x] Task 2: 修改 FusedDesktopComponentLibraryControl.axaml 控件布局
- [x] SubTask 2.1: 重新设计左侧面板:仅保留分类列表(移除搜索框)
- [x] SubTask 2.2: 重新设计右侧预览区:组件标题 + 大尺寸预览 + 描述 + 添加按钮
- [x] SubTask 2.3: 优化分类列表项样式,添加选中状态视觉反馈
- [x] SubTask 2.4: 复用阑山桌面组件库的分类图标映射
- [x] Task 3: 更新 ViewModel 支持新交互模式
- [x] SubTask 3.1: 在 ComponentLibraryWindowViewModel 中添加 SelectedComponent 属性
- [x] SubTask 3.2: 添加组件描述属性支持
- [x] Task 4: 更新 FusedDesktopComponentLibraryControl.axaml.cs 代码逻辑
- [x] SubTask 4.1: 修改分类选择逻辑,选中分类时显示该分类第一个组件
- [x] SubTask 4.2: 添加组件选中逻辑
- [x] SubTask 4.3: 移除搜索相关代码
- [x] SubTask 4.4: 复用阑山桌面组件库的分类图标和本地化方法
- [x] SubTask 4.5: 添加"查找更多组件"链接点击处理(打开设置窗口插件目录)
- [x] Task 5: 验证和测试
- [x] SubTask 5.1: 验证关闭按钮使用动态圆角资源 DesignCornerRadiusSm
- [x] SubTask 5.2: 验证窗口布局符合Windows 11小组件面板风格
- [x] SubTask 5.3: 验证分类图标与阑山桌面组件库一致
- [x] SubTask 5.4: 验证组件添加功能正常工作
- [x] SubTask 5.5: 验证"查找更多组件"链接能打开设置窗口
# Task Dependencies
- Task 3 依赖于 Task 1 和 Task 2 的UI设计确定
- Task 4 依赖于 Task 3 的ViewModel更新
- Task 5 依赖于所有前置任务完成

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

@@ -62,7 +62,10 @@ dotnet test LanMountainDesktop.slnx -c Debug
### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
- **圆角规范 (AI 强制建议)**
- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}`
- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token严禁硬编码像素值。
- **禁止修改系数**:严禁在圆角资源上乘以任何 `scale` 变量,圆角现在由全局样式固定控制。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`

237
CHANGELOG.md Normal file
View File

@@ -0,0 +1,237 @@
# 更新日志 / Changelog
## [0.8.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.4) - 2026-04-12
### 新增 (Added)
-**全新淡入淡出动画系统**: 引入了一套全新的淡入淡出动画效果
- 提升界面切换和元素显示的视觉流畅度
- 为用户带来更加自然优雅的交互体验
### 变更 (Changed)
- ♻️ **SDK 更新**: 更新插件 SDK优化插件开发接口和兼容性
- 🎨 **网速显示组件优化**: 优化了网速显示组件的显示效果
- 改进数据展示方式,提升可读性
- 优化视觉样式,与整体设计语言更加协调
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
-**插件设置页面支持 View 展示**: 插件设置页面现在支持使用 View 进行展示
- 插件开发者可以通过 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)
-
***
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
### 新增 (Added)
-**便签组件**: 全新便签组件上线,支持 Markdown 语法
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
-**白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
### 变更 (Changed)
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
- 提升视觉一致性和用户体验
- 统一组件风格,与整体设计语言保持协调
### 修复 (Fixed)
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
- 优化组件渲染逻辑,确保新闻内容正确展示
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
- 优化组件布局和渲染,确保课程信息正确显示
- 🐛 **轻量版 .NET 10 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 10 环境下的依赖问题
- 问题原因: 轻量版与 .NET 10 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升与 .NET 10 的兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.2](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2) - 2026-04-09
### 新增 (Added)
-**应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
- 用户可在设置中选择是否显示应用图标的专属卡片背景
- 关闭后仅显示应用图标本身,更加简洁
- 支持动态切换,实时预览效果
### 变更 (Changed)
-
### 修复 (Fixed)
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
- 🐛 **电源菜单重启导致关机问题**: 修复了点击电源菜单"重启"选项却触发关机的问题
- 问题原因: `SlideToShutDown.exe` 仅支持关机操作,不支持重启,错误地将其用于重启功能
- 修复方案: 重启操作改为使用标准的二次确认对话框(所有平台统一),仅关机操作使用 SlideToShutDown 滑动界面
- 🐛 **课表组件字体显示问题**: 修复了日间模式下课表组件字体颜色与背景色相近导致看不清的问题
- 问题原因: 主题切换时增量更新逻辑未同步更新文字颜色
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
### 移除 (Removed)
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
***
## [0.8.3.1](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1) - 2026-04-08
### 新增 (Added)
-**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式
- 支持配置是否显示背景
- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed)
-
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
所有重要的更改都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
***
## \[格式示例]
### 新增 (Added)
- 待发布的新功能
### 变更 (Changed)
- 待发布的变更
### 修复 (Fixed)
- 待发布的修复
### 移除 (Removed)
- 待发布的移除项
***
## 版本说明
### 版本号规则
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
- **MAJOR (主版本号)**: 不兼容的 API 修改
- **MINOR (次版本号)**: 向下兼容的功能性新增
- **PATCH (修订号)**: 向下兼容的问题修正
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
### 分类说明
- **新增 (Added)**: 新功能、新特性
- **变更 (Changed)**: 对现有功能的变更
- **修复 (Fixed)**: Bug 修复
- **移除 (Removed)**: 移除的功能或特性
### 图例
- 🎉 **重大更新**: 重要功能或里程碑
-**新功能**: 新增功能特性
- 🐛 **Bug修复**: 问题修复
- 🔧 **配置**: 配置相关变更
- 📝 **文档**: 文档更新
- 🎨 **样式**: UI/UX 改进
- ♻️ **重构**: 代码重构
-**性能**: 性能优化
- 🔒 **安全**: 安全相关
- 🌐 **国际化**: 国际化/本地化
***
## 链接

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

@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
public static class AppearanceCornerRadiusTokenFactory
{
public static AppearanceCornerRadiusTokens Create(double scale)
public static AppearanceCornerRadiusTokens Create(string style)
{
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)
{
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
return new CornerRadius(scaled);
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
return normalized switch
{
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(4),
Xs: new CornerRadius(8),
Sm: new CornerRadius(10),
Md: new CornerRadius(14),
Lg: new CornerRadius(20),
Xl: new CornerRadius(24),
Island: new CornerRadius(28),
Component: new CornerRadius(20)),
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(8),
Xs: new CornerRadius(14),
Sm: new CornerRadius(16),
Md: new CornerRadius(24),
Lg: new CornerRadius(32),
Xl: new CornerRadius(36),
Island: new CornerRadius(40),
Component: new CornerRadius(28)),
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(10),
Xs: new CornerRadius(16),
Sm: new CornerRadius(20),
Md: new CornerRadius(28),
Lg: new CornerRadius(36),
Xl: new CornerRadius(40),
Island: new CornerRadius(44),
Component: new CornerRadius(32)),
// Balanced (default)
_ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24))
};
}
}

View File

@@ -7,6 +7,5 @@ public sealed record ComponentChromeContext(
string ComponentId,
string? PlacementId,
double CellSize,
double GlobalCornerRadiusScale,
AppearanceCornerRadiusTokens CornerRadiusTokens,
SettingsScope Scope = SettingsScope.App);

View File

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

View File

@@ -0,0 +1,392 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher;
public partial class App : Application
{
public override void Initialize()
{
// 初始化日志记录器
Logger.Initialize();
Logger.Info("Launcher starting...");
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var context = LauncherRuntimeContext.Current;
// 调试模式:显示开发调试窗口
if (context.IsDebugMode)
{
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
base.OnFrameworkInitializationCompleted();
return;
}
// 处理各界面的预览命令
if (HandlePreviewCommand(context, desktop))
{
base.OnFrameworkInitializationCompleted();
return;
}
// apply-update 模式:显示 UpdateWindow执行增量更新 + 插件升级
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
// 先显示窗口,再启动后台任务
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
}
else
{
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow();
splashWindow.Show();
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
var command = context.Command.ToLowerInvariant();
switch (command)
{
case "preview-splash":
Console.WriteLine("[Launcher] Preview mode: SplashWindow");
var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true;
case "preview-error":
Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
case "preview-update":
Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true;
case "preview-oobe":
Console.WriteLine("[Launcher] Preview mode: OobeWindow");
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true;
case "preview-debug":
Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
return true;
default:
return false;
}
}
/// <summary>
/// 模拟 Splash 窗口预览
/// </summary>
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
var reporter = (ISplashStageReporter)window;
for (int i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800);
}
// 等待5秒后自动关闭
await Task.Delay(5000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 模拟 Update 窗口预览
/// </summary>
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (int i = 0; i < stages.Length; i++)
{
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
await Task.Delay(600);
}
window.ReportComplete(true, null);
// 等待3秒后自动关闭
await Task.Delay(3000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
string GetStageName(string stage) => stage switch
{
"verify" => "验证",
"extract" => "解压",
"apply" => "应用",
"plugins" => "升级插件",
"cleanup" => "清理",
_ => stage
};
}
/// <summary>
/// 模拟 OOBE 窗口预览
/// </summary>
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
// 等待用户点击开始按钮
await window.WaitForEnterAsync();
Console.WriteLine("[Launcher] OOBE preview completed by user");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
}
// 用户点击后关闭应用程序
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
/// <summary>
/// 等待窗口关闭
/// </summary>
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource();
window.Closed += (s, e) => tcs.TrySetResult();
await tcs.Task;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try
{
// 在 try-catch 块中实例化所有服务,确保异常被捕获
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService());
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
}
catch (Exception ex)
{
// 捕获异常并显示错误窗口
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"启动器发生错误: {ex.Message}",
ErrorMessage = ex.ToString()
};
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
// 安全关闭 Splash 窗口
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception closeEx)
{
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
}
// 创建并显示错误窗口
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整或尝试重新安装。");
errorWindow.Show();
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
}
catch (Exception windowEx)
{
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
}
});
// 如果错误窗口成功显示,等待它关闭
if (errorWindow != null)
{
try
{
// 等待用户选择或窗口关闭
var errorResult = await errorWindow.WaitForChoiceAsync();
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
}
catch (Exception waitEx)
{
Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
// 如果等待失败至少给用户5秒时间看到错误信息
await Task.Delay(5000);
}
}
else
{
// 错误窗口未能显示等待5秒让用户看到控制台输出
await Task.Delay(5000);
}
}
catch (Exception uiEx)
{
// 最后的兜底:记录到控制台
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
await Task.Delay(3000);
}
}
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
/// <summary>
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
/// </summary>
private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
// 1. 应用增量更新
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
// 2. 应用待处理的插件升级
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
var pluginsDir = context.GetOption("plugins-dir")
?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
// 插件升级失败不阻断整体流程,仅记录到控制台
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
}
}
// 3. 清理旧版本保留至少3个版本以支持回滚
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
}
// 显示完成状态,短暂停留后关闭
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
if (success)
{
// 成功:停留 1.5 秒让用户看到"更新完成"
await Task.Delay(1500);
}
else
{
// 失败:停留 5 秒让用户看到错误信息
await Task.Delay(5000);
}
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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,93 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public string Command { get; }
public string SubCommand { get; }
public IReadOnlyDictionary<string, string> Options { get; }
/// <summary>
/// 原始命令行参数,用于转发给主程序
/// </summary>
public IReadOnlyList<string> RawArgs { get; }
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
/// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用
/// </summary>
public bool IsDebugMode =>
Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached;
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{
Command = command;
SubCommand = subCommand;
Options = options;
RawArgs = rawArgs;
}
public static CommandContext FromArgs(string[] args)
{
var options = ParseOptions(args);
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
? args[0]
: "launch";
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options, args);
}
public string? GetOption(string key)
{
return Options.TryGetValue(key, out var value) ? value : null;
}
public int GetIntOption(string key, int fallback)
{
var raw = GetOption(key);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];
continue;
}
values[key] = "true";
}
return values;
}
}

View File

@@ -0,0 +1,62 @@
<!-- AOT 发布配置文件 -->
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 启用 Native AOT -->
<PublishAot>true</PublishAot>
<!-- 启用修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 自包含(不依赖系统 .NET Runtime -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 禁用 ReadyToRunAOT 不需要) -->
<PublishReadyToRun>false</PublishReadyToRun>
<!-- 注意RuntimeIdentifier 由 CI/CD 工作流通过 -r 参数传入,不在此处硬编码 -->
<!-- 支持的平台win-x64, win-x86, linux-x64, osx-x64, osx-arm64 -->
</PropertyGroup>
<!-- AOT 兼容性设置 -->
<PropertyGroup>
<!-- 允许不安全代码(某些 AOT 场景需要) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 启用编译时绑定Avalonia 需要) -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<!-- AOT 修剪配置 -->
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<!-- 保留 Avalonia 必要的类型 -->
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<!-- 保留动态序列化类型 -->
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 忽略某些警告 -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,29 @@
<!-- 单文件发布配置文件(非 AOT但接近单文件体验 -->
<Project>
<PropertyGroup Condition="'$(PublishSingleFileMode)' == 'true'">
<!-- 自包含 -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- ReadyToRun 预编译(提升启动速度) -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- 修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 目标运行时 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<!-- 导入 AOT 配置 -->
<Import Project="LanMountainDesktop.Launcher.AOT.props" Condition="Exists('LanMountainDesktop.Launcher.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- 资源文件 -->
<ItemGroup>
<!-- 公钥文件 -->
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

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,48 @@
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher;
internal static class Program
{
[STAThread]
private static async Task<int> Main(string[] args)
{
var commandContext = CommandContext.FromArgs(args);
// 处理遗留插件安装命令
if (commandContext.IsLegacyPluginInstall)
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
// 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

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,191 @@
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;
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
if (File.Exists(parentHost))
{
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -0,0 +1,40 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{
private ISplashStageReporter? _inner;
private readonly List<(string Stage, string Message)> _pending = [];
public void SetInner(ISplashStageReporter inner)
{
_inner = inner;
foreach (var (stage, message) in _pending)
{
_inner.Report(stage, message);
}
_pending.Clear();
}
public void Report(string stage, string message)
{
if (_inner is not null)
{
_inner.Report(stage, message);
}
else
{
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -0,0 +1,468 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeploymentLocator
{
private readonly string _appRoot;
public DeploymentLocator(string appRoot)
{
_appRoot = appRoot;
}
public string GetAppRoot() => _appRoot;
public string? FindCurrentDeploymentDirectory()
{
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
if (!Directory.Exists(_appRoot))
{
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
}
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
try
{
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
// ClassIsland 风格的查询:先筛选,后排序
var validInstallations = candidates
.Where(path =>
{
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
var hasExe = File.Exists(Path.Combine(path, executable));
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
var version = ParseVersionFromDirectory(path);
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
$"Version={version} | " +
$"Current={hasCurrent} | " +
$"Destroy={hasDestroy} | " +
$"Partial={hasPartial} | " +
$"HasExe={hasExe}");
return !hasDestroy && !hasPartial && hasExe;
})
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的排前面
.ThenByDescending(x => x.Version) // 然后按版本号降序
.ToList();
if (validInstallations.Count == 0)
{
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
return null;
}
var best = validInstallations[0];
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
return best.Path;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
return null;
}
}
public string? ResolveHostExecutablePath()
{
// 使用新的灵活定位器
var options = new HostDiscoveryOptions
{
ExecutableName = "LanMountainDesktop",
PreferDevModeConfig = true,
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
AdditionalSearchPaths = new List<string>
{
// 可以通过配置文件或环境变量添加更多路径
"${AppRoot}",
"${AppRoot}/..",
"${BaseDirectory}/../..",
}
};
var locator = new FlexibleHostLocator(_appRoot, options);
var result = locator.ResolveHostExecutablePath();
if (result != null)
{
return result;
}
// 回退到旧逻辑(作为备选)
return ResolveHostExecutablePathLegacy();
}
/// <summary>
/// 传统的主程序路径解析(作为备选)
/// </summary>
private string? ResolveHostExecutablePathLegacy()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 首先查找 app-{version} 目录(生产环境)
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent))
{
return inParent;
}
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
if (Views.ErrorWindow.CheckDevModeEnabled())
{
// 4.1 首先检查保存的自定义路径
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
{
return savedCustomPath;
}
// 4.2 扫描开发路径
var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
}
// 5. 开发模式:查找主程序项目的输出目录
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
if (File.Exists(devPath))
{
return devPath;
}
}
return null;
}
/// <summary>
/// 扫描开发路径(开发模式)
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
// 从 Launcher 项目运行
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从解决方案根目录运行
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// dev-test 目录
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 获取开发环境可能的主程序路径
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 可能的开发目录结构
var possiblePaths = new[]
{
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从解决方案根目录运行LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// 从 dev-test 目录运行
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(deployment))
{
return "0.0.0";
}
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
/// <summary>
/// 清理旧版本部署保留最近的N个版本
/// </summary>
/// <param name="minVersionsToKeep">最少保留版本数默认3个</param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
{
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
if (!Directory.Exists(_appRoot))
{
return;
}
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
// 过滤掉无效部署目录排除partial按版本排序
var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
IsCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderByDescending(item => item.Version)
.ToList();
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 确定要保留的版本
var versionsToKeep = new HashSet<string>();
// 1. 总是保留当前版本
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
versionsToKeep.Add(currentVersion.Path);
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 保留最近的N个有效版本不包括已标记destroy的
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
.ToList();
foreach (var ver in activeVersions)
{
versionsToKeep.Add(ver.Path);
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. 保留有快照的版本(用于回滚)
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
{
try
{
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
foreach (var snapshotFile in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
{
versionsToKeep.Add(snapshot.SourceDirectory);
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
}
}
}
catch
{
// 忽略快照解析错误
}
}
}
catch
{
// 忽略快照目录访问错误
}
}
// 清理不需要的版本
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
{
// 保留此版本如果之前标记了destroy则取消标记
if (deployment.IsDestroyed)
{
try
{
File.Delete(Path.Combine(deployment.Path, ".destroy"));
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
}
catch
{
// 忽略取消标记失败
}
}
continue;
}
// 如果还没标记destroy的先标记
if (!deployment.IsDestroyed)
{
try
{
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
}
catch
{
// 忽略标记失败
}
}
// 尝试删除
try
{
Directory.Delete(deployment.Path, recursive: true);
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 忽略清理失败
}
}
/// <summary>
/// 仅清理已标记为.destroy的部署兼容旧方法
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
{
CleanupOldDeployments(3);
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return null;
}
return segments[1];
}
/// <summary>
/// 从部署目录读取版本信息
/// </summary>
public AppVersionInfo GetVersionInfo()
{
var deploymentDir = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var versionFile = Path.Combine(deploymentDir, "version.json");
if (File.Exists(versionFile))
{
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize<AppVersionInfo>(json);
if (info is not null)
{
return info;
}
}
catch
{
// 忽略读取失败,回退到默认值
}
}
}
// 回退:从目录名解析版本,使用默认开发代号
return new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate" // 默认开发代号
};
}
}

View File

@@ -0,0 +1,629 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 灵活的主程序定位器
/// </summary>
internal sealed class FlexibleHostLocator
{
private readonly HostDiscoveryOptions _options;
private readonly string _appRoot;
private readonly DeploymentLocator _deploymentLocator;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
}
/// <summary>
/// 解析主程序可执行文件路径
/// </summary>
public string? ResolveHostExecutablePath()
{
var executable = GetExecutableName();
var searchContext = new SearchContext
{
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)==========
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{
var validated = ValidateAndReturn(envPath, "environment variable");
if (validated != null) return validated;
}
// 2. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var deploymentExePath = Path.Combine(deploymentDir, executable);
if (File.Exists(deploymentExePath))
{
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
return deploymentExePath;
}
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
}
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 4. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath))
{
var validated = ValidateAndReturn(configPath, "config file");
if (validated != null) return validated;
}
// 5. 搜索附近目录(向上、向下各一层)
var nearbyPath = SearchNearbyDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(nearbyPath))
{
return nearbyPath;
}
// 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedPath))
{
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
if (validated != null) return validated;
}
}
// 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 10. 递归搜索(如果启用)
if (_options.RecursiveSearch)
{
var recursivePath = SearchRecursively(searchContext);
if (!string.IsNullOrWhiteSpace(recursivePath))
{
return recursivePath;
}
}
return null;
}
/// <summary>
/// 从环境变量获取路径
/// </summary>
private string? GetPathFromEnvironment()
{
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
{
return null;
}
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
return path;
}
/// <summary>
/// 从配置文件获取路径
/// </summary>
private string? GetPathFromConfigFile()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
{
return null;
}
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
if (!File.Exists(configPath))
{
return null;
}
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
}
}
catch
{
// 忽略配置文件读取错误
}
return null;
}
/// <summary>
/// 搜索部署目录
/// </summary>
private string? SearchDeploymentDirectories(SearchContext context)
{
if (!Directory.Exists(_appRoot))
{
return null;
}
try
{
// 查找 app-* 目录
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
.ToList();
// 优先选择带 .current 标记的
var currentMarked = appDirs
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
.Select(dir => Path.Combine(dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
if (currentMarked != null)
{
return currentMarked;
}
// 选择版本号最高的
var latest = appDirs
.Select(dir => new
{
Dir = dir,
Version = ParseVersionFromDirectoryName(dir)
})
.OrderByDescending(x => x.Version)
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
return latest;
}
catch
{
return null;
}
}
/// <summary>
/// 搜索便携模式位置Launcher 同级目录)
/// </summary>
private string? SearchPortableLocation(SearchContext context)
{
try
{
var launcherDir = AppContext.BaseDirectory;
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
if (File.Exists(portablePath))
{
return portablePath;
}
}
catch
{
// 忽略错误
}
return null;
}
/// <summary>
/// 搜索附近目录(灵活查找,适用于各种部署场景)
/// </summary>
private string? SearchNearbyDirectories(SearchContext context)
{
try
{
var searchDirs = new List<string>();
// Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
searchDirs.Add(launcherDir);
// 上级目录
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
searchDirs.Add(parentDir);
}
// 上上级目录
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
if (Directory.Exists(grandparentDir))
{
searchDirs.Add(grandparentDir);
}
// AppRoot 及其上级
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
{
searchDirs.Add(_appRoot);
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
if (Directory.Exists(appParent))
{
searchDirs.Add(appParent);
}
}
// 去重后搜索
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
{
// 直接搜索
var directPath = Path.Combine(dir, context.ExecutableName);
if (File.Exists(directPath))
{
return directPath;
}
// 搜索子目录(一层)
if (Directory.Exists(dir))
{
foreach (var subDir in Directory.GetDirectories(dir))
{
var subPath = Path.Combine(subDir, context.ExecutableName);
if (File.Exists(subPath))
{
return subPath;
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return null;
}
/// <summary>
/// 搜索开发路径
/// </summary>
private string? SearchDevelopmentPaths(SearchContext context)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 动态构建可能的开发路径(支持不同的项目结构)
var possiblePaths = new List<string>();
// 从解决方案根目录搜索(支持不同的解决方案结构)
var solutionRoot = FindSolutionRoot(launcherDir);
if (!string.IsNullOrWhiteSpace(solutionRoot))
{
// 搜索所有可能的 bin 目录
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
}
// 添加硬编码的备用路径
possiblePaths.AddRange(new[]
{
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
});
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 搜索额外的配置路径
/// </summary>
private string? SearchAdditionalPaths(SearchContext context)
{
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
{
return null;
}
foreach (var pattern in _options.AdditionalSearchPaths)
{
try
{
// 替换变量
var expandedPattern = ExpandVariables(pattern);
// 支持通配符
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
{
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
var filePattern = Path.GetFileName(expandedPattern);
if (Directory.Exists(dir))
{
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
var validMatch = matches.FirstOrDefault(File.Exists);
if (validMatch != null)
{
return validMatch;
}
}
}
else if (File.Exists(expandedPattern))
{
return expandedPattern;
}
}
catch
{
// 忽略搜索错误
}
}
return null;
}
/// <summary>
/// 递归搜索
/// </summary>
private string? SearchRecursively(SearchContext context)
{
try
{
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
foreach (var searchDir in searchDirs.Where(Directory.Exists))
{
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略递归搜索错误
}
return null;
}
/// <summary>
/// 递归搜索目录
/// </summary>
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
{
if (depth > _options.MaxRecursionDepth)
{
return null;
}
try
{
// 检查当前目录
var directPath = Path.Combine(dir, executableName);
if (File.Exists(directPath))
{
return directPath;
}
// 检查子目录
foreach (var subDir in Directory.GetDirectories(dir))
{
// 跳过某些目录
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
{
continue;
}
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略访问错误
}
return null;
}
/// <summary>
/// 查找解决方案根目录
/// </summary>
private string? FindSolutionRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
// 查找 .sln 文件
if (current.GetFiles("*.sln").Any())
{
return current.FullName;
}
// 查找 .git 目录作为备选
if (current.GetDirectories(".git").Any())
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
/// <summary>
/// 搜索 bin 目录
/// </summary>
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
{
var results = new List<string>();
try
{
// 查找所有 bin 目录
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
foreach (var binDir in binDirs)
{
// 检查 Debug 和 Release 子目录
var configDirs = new[] { "Debug", "Release" };
foreach (var config in configDirs)
{
var configPath = Path.Combine(binDir, config);
if (Directory.Exists(configPath))
{
// 检查所有 net* 子目录
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
foreach (var fwDir in frameworkDirs)
{
var exePath = Path.Combine(fwDir, executableName);
if (File.Exists(exePath))
{
results.Add(exePath);
}
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return results;
}
/// <summary>
/// 验证路径并返回
/// </summary>
private string? ValidateAndReturn(string path, string source)
{
if (File.Exists(path))
{
Debug.WriteLine($"Found host executable from {source}: {path}");
return path;
}
// 尝试添加 .exeWindows
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
var withExe = path + ".exe";
if (File.Exists(withExe))
{
Debug.WriteLine($"Found host executable from {source}: {withExe}");
return withExe;
}
}
return null;
}
/// <summary>
/// 获取可执行文件名
/// </summary>
private string GetExecutableName()
{
var name = _options.ExecutableName;
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name += ".exe";
}
return name;
}
/// <summary>
/// 展开路径变量
/// </summary>
private string ExpandVariables(string path)
{
return path
.Replace("${AppRoot}", _appRoot)
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
/// <summary>
/// 从目录名解析版本
/// </summary>
private static Version ParseVersionFromDirectoryName(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return new Version(0, 0, 0);
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return new Version(0, 0, 0);
}
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
}
/// <summary>
/// 搜索上下文
/// </summary>
private class SearchContext
{
public required string ExecutableName { get; set; }
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
/// <summary>
/// 发现配置文件
/// </summary>
private class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主程序发现选项
/// </summary>
public sealed class HostDiscoveryOptions
{
/// <summary>
/// 可执行文件名Windows 下自动添加 .exe
/// </summary>
public string ExecutableName { get; set; } = "LanMountainDesktop";
/// <summary>
/// 额外的搜索路径(支持通配符)
/// </summary>
public List<string> AdditionalSearchPaths { get; set; } = new();
/// <summary>
/// 是否递归搜索子目录
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// 递归搜索的最大深度
/// </summary>
public int MaxRecursionDepth { get; set; } = 3;
/// <summary>
/// 环境变量名称,用于指定自定义路径
/// </summary>
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
/// <summary>
/// 配置文件路径(相对于 app root
/// </summary>
public string? ConfigFileName { get; set; } = "host-discovery.json";
/// <summary>
/// 是否优先使用开发模式配置
/// </summary>
public bool PreferDevModeConfig { get; set; } = true;
/// <summary>
/// 搜索超时(毫秒)
/// </summary>
public int SearchTimeoutMs { get; set; } = 5000;
}

View File

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

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
void ReportStage(string stage, int progress);
}

View File

@@ -0,0 +1,192 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
private NamedPipeServerStream? _currentPipe;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
/// </summary>
private const int LengthPrefixSize = 4;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
public void Start()
{
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
}
private async Task ListenLoopAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
NamedPipeServerStream? pipe = null;
try
{
pipe = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte);
_currentPipe = pipe;
await pipe.WaitForConnectionAsync(_cts.Token);
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
{
// 客户端断开连接,继续等待新连接
continue;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
try
{
pipe?.Dispose();
}
catch { }
if (ReferenceEquals(_currentPipe, pipe))
{
_currentPipe = null;
}
}
}
}
/// <summary>
/// 从已连接的管道中持续读取消息,直到连接断开或取消
/// </summary>
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
{
// 1. 读取 4 字节长度前缀
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
if (read == 0)
{
// 连接已关闭
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
{
// 无效长度,跳过此连接
return;
}
// 2. 读取消息正文
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
if (read == 0)
{
return;
}
totalRead += read;
}
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
if (message is not null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误,继续读取下一条消息
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -0,0 +1,442 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine;
private readonly UpdateCheckService _updateCheckService;
private readonly PluginInstallerService _pluginInstallerService;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
public LauncherFlowCoordinator(
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
UpdateEngineService updateEngine,
UpdateCheckService updateCheckService,
PluginInstallerService pluginInstallerService)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_updateCheckService = updateCheckService;
_pluginInstallerService = pluginInstallerService;
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
{
try
{
// 清理旧版本保留至少3个版本
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
// 使用传入的 Splash 窗口或创建新的
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
var reporter = (ISplashStageReporter)splashWindow;
// 跟踪主程序是否已就绪,就绪后自动关闭 Splash 窗口
var hostReadyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
// 主程序报告就绪后,关闭 Splash 窗口
if (msg.Stage == StartupStage.Ready && splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
hostReadyTcs.TrySetResult();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error in IPC callback: {ex.Message}");
}
});
});
ipcServer.Start();
try
{
// 检查并安装待处理的更新(主程序下载的)
reporter.Report("update", "检查更新...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
return updateResult;
}
// 检查并安装待处理的插件更新
reporter.Report("plugins", "检查插件更新...");
var pluginsDir = _context.GetOption("plugins-dir")
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
{
return queueResult;
}
// OOBE首次运行引导
if (_oobeStateService.IsFirstRun())
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
}
// 启动主程序
reporter.Report("launch", "正在启动...");
var (hostResult, hostProcess) = await LaunchHostWithIpcAsync(splashWindow);
if (!hostResult.Success)
{
return hostResult;
}
// 等待主程序进程退出。Launcher 作为后台守护进程保持运行,
// 维持 IPC 管道服务端供主程序报告启动进度。
if (hostProcess is not null)
{
// 等待主程序就绪或进程退出(取先发生者)
// 如果主程序在 60 秒内未报告 Ready也关闭 Splash 窗口作为超时保护
var readyOrTimeout = Task.WhenAny(
hostReadyTcs.Task,
Task.Delay(TimeSpan.FromSeconds(60)));
var processExitTask = hostProcess.WaitForExitAsync();
// 先等待就绪/超时,然后等待进程退出
await readyOrTimeout;
// 如果 Splash 窗口仍然打开(超时情况),关闭它
if (splashWindow.IsVisible)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window on timeout: {ex.Message}");
}
});
}
await processExitTask;
}
else
{
// 如果无法获取进程引用,退回到有限等待
await Task.Delay(TimeSpan.FromSeconds(30));
}
return new LauncherResult
{
Success = true,
Stage = "exit",
Code = "ok",
Message = "Launcher completed successfully."
};
}
finally
{
// Splash 窗口可能已由 IPC Ready 回调关闭,这里做安全清理
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] Splash window closed in finally block");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window in finally: {ex.Message}");
}
});
}
}
catch (Exception ex)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.ToString()
};
}
}
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
{
// 优先使用自定义路径(调试模式选择的路径)
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
if (string.IsNullOrWhiteSpace(hostPath))
{
// 关闭 Splash 窗口
// 显示错误窗口而不是直接退出
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
if (errorResult == ErrorWindowResult.Retry)
{
// 用户选择重试,如果有选择路径则使用,否则重新尝试
if (!string.IsNullOrWhiteSpace(selectedPath))
{
return await LaunchHostWithIpcAsync(splashWindow, selectedPath);
}
return await LaunchHostWithIpcAsync(splashWindow);
}
// 用户选择退出
return (new LauncherResult
{
Success = false,
Stage = "launchHost",
Code = "host_not_found",
Message = "LanMountainDesktop host executable not found."
}, null);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
EnsureExecutable(hostPath);
}
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
};
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
var hostProcess = Process.Start(processStartInfo);
return (new LauncherResult
{
Success = true,
Stage = "launchHost",
Code = "ok",
Message = "Host launched."
}, hostProcess);
}
/// <summary>
/// 显示找不到主程序的错误窗口
/// </summary>
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
ErrorWindow? errorWindow = null;
// 在 UI 线程创建并显示错误窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
errorWindow.Show();
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow shown for host not found");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to show ErrorWindow: {ex.Message}");
}
});
if (errorWindow is null)
{
Console.Error.WriteLine("[LauncherFlowCoordinator] ErrorWindow is null, cannot wait for choice");
return (ErrorWindowResult.Exit, null);
}
// 等待用户选择
ErrorWindowResult result;
string? customPath;
try
{
result = await errorWindow.WaitForChoiceAsync();
customPath = errorWindow.GetCustomHostPath();
Console.WriteLine($"[LauncherFlowCoordinator] ErrorWindow result: {result}, customPath: {customPath != null}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error waiting for choice: {ex.Message}");
result = ErrorWindowResult.Exit;
customPath = null;
}
// 安全关闭错误窗口
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (errorWindow.IsVisible && errorWindow.IsLoaded)
{
errorWindow.Close();
Console.WriteLine("[LauncherFlowCoordinator] ErrorWindow closed successfully");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing ErrorWindow: {ex.Message}");
}
});
return (result, customPath);
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
var mode = File.GetUnixFileMode(path);
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
File.SetUnixFileMode(path, mode);
}
catch
{
}
}
private sealed class WelcomeOobeStep : IOobeStep
{
private readonly OobeStateService _stateService;
public WelcomeOobeStep(OobeStateService stateService)
{
_stateService = stateService;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
OobeWindow? window = null;
try
{
window = await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
Console.WriteLine("[WelcomeOobeStep] OOBE window shown");
return oobeWindow;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Failed to show OOBE window: {ex.Message}");
return null;
}
});
if (window is null)
{
Console.Error.WriteLine("[WelcomeOobeStep] OOBE window is null, skipping OOBE");
_stateService.MarkCompleted();
return;
}
using var _ = cancellationToken.Register(() =>
{
try
{
if (window.IsVisible && window.IsLoaded)
{
window.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window on cancel: {ex.Message}");
}
});
await window.WaitForEnterAsync().ConfigureAwait(false);
Console.WriteLine("[WelcomeOobeStep] OOBE completed by user");
_stateService.MarkCompleted();
}
finally
{
if (window is not null)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (window.IsVisible && window.IsLoaded)
{
window.Close();
Console.WriteLine("[WelcomeOobeStep] OOBE window closed in finally");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WelcomeOobeStep] Error closing OOBE window in finally: {ex.Message}");
}
});
}
}
}
}
}

View File

@@ -0,0 +1,138 @@
using System.Text;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 简单的日志记录器 - 同时输出到控制台和文件
/// </summary>
internal static class Logger
{
private static readonly object _lock = new();
private static string? _logFilePath;
private static bool _initialized;
/// <summary>
/// 初始化日志记录器
/// </summary>
public static void Initialize()
{
if (_initialized)
{
return;
}
try
{
var logDir = GetLogDirectory();
if (!string.IsNullOrEmpty(logDir))
{
Directory.CreateDirectory(logDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
_logFilePath = Path.Combine(logDir, $"launcher_{timestamp}.log");
Console.WriteLine($"[Logger] Log file initialized: {_logFilePath}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Logger] Failed to initialize log file: {ex.Message}");
}
_initialized = true;
}
/// <summary>
/// 获取日志文件路径
/// </summary>
public static string? GetLogFilePath()
{
return _logFilePath;
}
/// <summary>
/// 获取日志目录
/// </summary>
private static string? GetLogDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
}
}
catch
{
}
try
{
var launcherDir = AppContext.BaseDirectory;
return Path.Combine(launcherDir, ".launcher", "logs");
}
catch
{
}
return null;
}
/// <summary>
/// 记录信息日志
/// </summary>
public static void Info(string message)
{
WriteLog("INFO", message);
}
/// <summary>
/// 记录警告日志
/// </summary>
public static void Warn(string message)
{
WriteLog("WARN", message);
}
/// <summary>
/// 记录错误日志
/// </summary>
public static void Error(string message)
{
WriteLog("ERROR", message);
}
/// <summary>
/// 记录错误日志(带异常)
/// </summary>
public static void Error(string message, Exception exception)
{
WriteLog("ERROR", $"{message}\n{exception}");
}
/// <summary>
/// 写入日志
/// </summary>
private static void WriteLog(string level, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logLine = $"[{timestamp}] [{level}] {message}";
Console.WriteLine(logLine);
if (string.IsNullOrEmpty(_logFilePath))
{
return;
}
try
{
lock (_lock)
{
File.AppendAllText(_logFilePath, logLine + Environment.NewLine, Encoding.UTF8);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,104 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService
{
private readonly string _markerPath;
public OobeStateService(string appRoot)
{
// 优先使用 LocalApplicationData用户目录普通用户一定有权限
string? stateDir = null;
Exception? lastException = null;
// 策略1: LocalApplicationData首选用户目录普通用户一定有写权限
try
{
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
stateDir = Path.Combine(appDataDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
stateDir = null;
}
// 策略2: 如果LocalApplicationData不行使用用户的临时目录
if (stateDir == null)
{
try
{
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
Directory.CreateDirectory(tempDir);
stateDir = tempDir;
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
stateDir = null;
}
}
// 策略3: 最后的兜底使用当前用户的应用程序数据目录和Launcher同目录
if (stateDir == null)
{
try
{
var launcherDir = AppContext.BaseDirectory;
stateDir = Path.Combine(launcherDir, ".launcher", "state");
Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
}
catch (Exception ex)
{
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
// 如果所有策略都失败,抛出异常让上层处理
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
}
}
_markerPath = Path.Combine(stateDir, "first_run_completed");
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
}
public bool IsFirstRun()
{
try
{
return !File.Exists(_markerPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
// 如果无法检查默认视为首次运行确保OOBE能显示
return true;
}
}
public void MarkCompleted()
{
try
{
var dir = Path.GetDirectoryName(_markerPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
Console.WriteLine("[OobeStateService] Marked first run as completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
// 如果无法写入也没关系下次启动还会显示OOBE
}
}
}

View File

@@ -0,0 +1,226 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 插件安装服务 - 简化版,不依赖 PluginSdk
/// </summary>
internal sealed class PluginInstallerService
{
private const string ManifestFileName = "manifest.json";
private const string PackageFileExtension = ".lmdp";
private const string RuntimeDirectoryName = "runtime";
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
{
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
return new LauncherResult
{
Success = true,
Stage = "plugin.install",
Code = "ok",
Message = "Plugin installed.",
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
}
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
if (manifest == null)
{
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
}
return manifest;
}
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
File.Move(existingPackagePath, pendingPath);
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
}
/// <summary>
/// 简化的插件清单模型
/// </summary>
public class PluginManifest
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Version { get; set; } = "";
public string? Description { get; set; }
public string? Author { get; set; }
}

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,676 @@
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 await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
}
var currentVersion = _deploymentLocator.GetCurrentVersion();
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
{
return Failed(
"update.apply",
"version_mismatch",
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
}
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = currentVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
Status = "pending"
};
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
SaveSnapshot(snapshotPath, snapshot);
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
foreach (var file in fileMap.Files)
{
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
}
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
{
continue;
}
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
ActivateDeployment(currentDeployment, targetDeployment);
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
// 清理旧版本但保留最近3个版本以支持回滚
CleanupDestroyedDeployments();
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Updated to {targetVersion}.",
CurrentVersion = currentVersion,
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
TryRollbackOnFailure(snapshot);
snapshot.Status = "rolled_back";
SaveSnapshot(snapshotPath, snapshot);
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = "apply_failed",
Message = "Failed to apply update. Rolled back to previous version.",
ErrorMessage = ex.Message,
CurrentVersion = currentVersion,
RolledBackTo = currentVersion
};
}
finally
{
try
{
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
}
catch
{
}
}
}
/// <summary>
/// 全新安装场景:直接应用更新包作为首次部署
/// </summary>
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
SignedFileMap fileMap,
string archivePath,
string fileMapPath,
string signaturePath)
{
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!;
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json");
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = "0.0.0",
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = "",
TargetDirectory = targetDeployment,
Status = "pending"
};
SaveSnapshot(snapshotPath, snapshot);
// 清理并解压更新包
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
// 创建目标部署目录
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
// 应用所有文件(全新安装时,所有文件都是新增或替换)
foreach (var file in fileMap.Files)
{
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
}
// 验证文件哈希
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
{
continue;
}
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
// 激活部署(创建 .current 标记,删除 .partial 标记)
var currentMarker = Path.Combine(targetDeployment, ".current");
File.WriteAllText(currentMarker, string.Empty);
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
// 清理更新包
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Initial deployment to {targetVersion}.",
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
// 清理失败的目标目录
try
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, true);
}
}
catch
{
}
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = "initial_deploy_failed",
Message = "Failed to apply initial deployment.",
ErrorMessage = ex.Message,
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
};
}
finally
{
try
{
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
}
catch
{
}
}
}
/// <summary>
/// 应用初始部署文件(全新安装场景,不需要源目录)
/// </summary>
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
{
var normalizedPath = NormalizeRelativePath(file.Path);
// 删除操作在全新安装时忽略
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
var targetPath = Path.Combine(targetDeployment, normalizedPath);
EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
// 无论是 add 还是 replace都从压缩包复制
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(extractRoot, archiveRelative);
EnsurePathWithinRoot(extractedPath, extractRoot);
if (!File.Exists(extractedPath))
{
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
}
File.Copy(extractedPath, targetPath, overwrite: true);
}
public LauncherResult RollbackLatest()
{
if (!Directory.Exists(_snapshotsRoot))
{
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshotPath = Directory
.EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(File.GetCreationTimeUtc)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(snapshotPath))
{
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshot = JsonSerializer.Deserialize<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,263 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace LanMountainDesktop.Launcher.ViewModels;
/// <summary>
/// 开发调试窗口 ViewModel
/// </summary>
public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
{
private bool _isSplashEnabled = true;
private bool _isErrorEnabled = true;
private bool _isUpdateEnabled = true;
private bool _isOobeEnabled = true;
private string _statusMessage = "就绪";
public event PropertyChangedEventHandler? PropertyChanged;
#region
/// <summary>
/// 启动画面是否启用实际功能
/// </summary>
public bool IsSplashEnabled
{
get => _isSplashEnabled;
set
{
if (_isSplashEnabled != value)
{
_isSplashEnabled = value;
OnPropertyChanged();
UpdateStatus($"启动画面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// 错误页面是否启用实际功能
/// </summary>
public bool IsErrorEnabled
{
get => _isErrorEnabled;
set
{
if (_isErrorEnabled != value)
{
_isErrorEnabled = value;
OnPropertyChanged();
UpdateStatus($"错误页面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// 更新页面是否启用实际功能
/// </summary>
public bool IsUpdateEnabled
{
get => _isUpdateEnabled;
set
{
if (_isUpdateEnabled != value)
{
_isUpdateEnabled = value;
OnPropertyChanged();
UpdateStatus($"更新页面: {(value ? "" : "")}");
}
}
}
/// <summary>
/// OOBE页面是否启用实际功能
/// </summary>
public bool IsOobeEnabled
{
get => _isOobeEnabled;
set
{
if (_isOobeEnabled != value)
{
_isOobeEnabled = value;
OnPropertyChanged();
UpdateStatus($"OOBE页面: {(value ? "" : "")}");
}
}
}
#endregion
#region
/// <summary>
/// 状态消息
/// </summary>
public string StatusMessage
{
get => _statusMessage;
private set
{
if (_statusMessage != value)
{
_statusMessage = value;
OnPropertyChanged();
}
}
}
#endregion
#region
/// <summary>
/// 打开启动画面命令
/// </summary>
public ICommand OpenSplashCommand { get; }
/// <summary>
/// 打开错误页面命令
/// </summary>
public ICommand OpenErrorCommand { get; }
/// <summary>
/// 打开更新页面命令
/// </summary>
public ICommand OpenUpdateCommand { get; }
/// <summary>
/// 打开OOBE页面命令
/// </summary>
public ICommand OpenOobeCommand { get; }
/// <summary>
/// 全部切换到查看模式命令
/// </summary>
public ICommand SetAllViewOnlyCommand { get; }
/// <summary>
/// 全部切换到功能模式命令
/// </summary>
public ICommand SetAllFunctionalCommand { get; }
/// <summary>
/// 关闭窗口命令
/// </summary>
public ICommand CloseCommand { get; }
#endregion
#region
/// <summary>
/// 请求打开启动画面
/// </summary>
public event EventHandler<SplashOpenEventArgs>? OpenSplashRequested;
/// <summary>
/// 请求打开错误页面
/// </summary>
public event EventHandler<ErrorOpenEventArgs>? OpenErrorRequested;
/// <summary>
/// 请求打开更新页面
/// </summary>
public event EventHandler<UpdateOpenEventArgs>? OpenUpdateRequested;
/// <summary>
/// 请求打开OOBE页面
/// </summary>
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
/// <summary>
/// 请求关闭窗口
/// </summary>
public event EventHandler? CloseRequested;
#endregion
public DevDebugWindowViewModel()
{
OpenSplashCommand = new RelayCommand(() =>
{
OpenSplashRequested?.Invoke(this, new SplashOpenEventArgs(IsSplashEnabled));
});
OpenErrorCommand = new RelayCommand(() =>
{
OpenErrorRequested?.Invoke(this, new ErrorOpenEventArgs(IsErrorEnabled));
});
OpenUpdateCommand = new RelayCommand(() =>
{
OpenUpdateRequested?.Invoke(this, new UpdateOpenEventArgs(IsUpdateEnabled));
});
OpenOobeCommand = new RelayCommand(() =>
{
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
});
SetAllViewOnlyCommand = new RelayCommand(() =>
{
IsSplashEnabled = false;
IsErrorEnabled = false;
IsUpdateEnabled = false;
IsOobeEnabled = false;
UpdateStatus("全部页面已切换到查看模式");
});
SetAllFunctionalCommand = new RelayCommand(() =>
{
IsSplashEnabled = true;
IsErrorEnabled = true;
IsUpdateEnabled = true;
IsOobeEnabled = true;
UpdateStatus("全部页面已切换到功能模式");
});
CloseCommand = new RelayCommand(() =>
{
CloseRequested?.Invoke(this, EventArgs.Empty);
});
}
private void UpdateStatus(string message)
{
StatusMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
#region
public class SplashOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public SplashOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class ErrorOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public ErrorOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class UpdateOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public UpdateOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
public class OobeOpenEventArgs : EventArgs
{
public bool IsFunctional { get; }
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
}
#endregion

View File

@@ -0,0 +1,67 @@
using System.Windows.Input;
namespace LanMountainDesktop.Launcher.ViewModels;
/// <summary>
/// 简单的命令实现
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke() ?? true;
}
public void Execute(object? parameter)
{
_execute();
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// 带参数的 RelayCommand
/// </summary>
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Predicate<T>? _canExecute;
public RelayCommand(Action<T> execute, Predicate<T>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke((T)parameter!) ?? true;
}
public void Execute(object? parameter)
{
_execute((T)parameter!);
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,182 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LanMountainDesktop.Launcher.ViewModels"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="600"
x:Class="LanMountainDesktop.Launcher.Views.DevDebugWindow"
x:DataType="vm:DevDebugWindowViewModel"
Title="开发调试窗口 - Launcher"
Width="500"
Height="600"
WindowStartupLocation="CenterScreen"
Icon="/Assets/logo.ico">
<Design.DataContext>
<vm:DevDebugWindowViewModel />
</Design.DataContext>
<Border Padding="20"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题 -->
<StackPanel Grid.Row="0" Margin="0,0,0,20">
<TextBlock Text="🛠️ 开发调试窗口"
FontSize="24"
FontWeight="Bold"
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
<TextBlock Text="用于开发和调试 Launcher 的各个页面"
FontSize="12"
Opacity="0.7"
Margin="0,5,0,0"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<!-- 页面列表 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15">
<!-- 启动画面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="🚀 启动画面 (SplashWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示启动进度和状态"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsSplashEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenSplashCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- 错误页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="❌ 错误页面 (ErrorWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示错误信息和重试选项"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsErrorEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenErrorCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- 更新页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="⬆️ 更新页面 (UpdateWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="显示更新进度和状态"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsUpdateEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenUpdateCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
<!-- OOBE页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="👋 OOBE页面 (OobeWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="首次运行引导页面"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsOobeEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenOobeCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<!-- 批量操作 -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="10"
Margin="0,15">
<Button Content="全部设为查看模式"
Command="{Binding SetAllViewOnlyCommand}"
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}" />
<Button Content="全部设为功能模式"
Command="{Binding SetAllFunctionalCommand}"
Background="{DynamicResource SystemControlHighlightAccentBrush}"
Foreground="White" />
</StackPanel>
<!-- 底部状态栏 -->
<Border Grid.Row="3"
Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="4"
Padding="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding StatusMessage}"
FontSize="11"
Opacity="0.8"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="关闭"
Command="{Binding CloseCommand}"
Padding="15,5" />
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,196 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.ViewModels;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 开发调试窗口
/// </summary>
public partial class DevDebugWindow : Window
{
private readonly DevDebugWindowViewModel _viewModel;
public DevDebugWindow()
{
AvaloniaXamlLoader.Load(this);
_viewModel = new DevDebugWindowViewModel();
DataContext = _viewModel;
// 订阅事件
_viewModel.OpenSplashRequested += OnOpenSplashRequested;
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
_viewModel.CloseRequested += OnCloseRequested;
}
/// <summary>
/// 打开启动画面
/// </summary>
private void OnOpenSplashRequested(object? sender, SplashOpenEventArgs e)
{
var splashWindow = new SplashWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟内容
splashWindow.SetDebugMode(true);
}
splashWindow.Show();
if (e.IsFunctional)
{
// 功能模式:模拟正常启动流程
_ = SimulateSplashProgress(splashWindow);
}
}
/// <summary>
/// 打开错误页面
/// </summary>
private void OnOpenErrorRequested(object? sender, ErrorOpenEventArgs e)
{
var errorWindow = new ErrorWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟错误
errorWindow.SetDebugMode(true);
errorWindow.SetErrorMessage("[调试模式] 这是一个模拟的错误消息,用于查看错误页面的样式和布局。");
}
else
{
// 功能模式:显示真实错误
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。\n\n请检查应用安装是否完整。");
}
errorWindow.Show();
}
/// <summary>
/// 打开更新页面
/// </summary>
private void OnOpenUpdateRequested(object? sender, UpdateOpenEventArgs e)
{
var updateWindow = new UpdateWindow();
if (!e.IsFunctional)
{
// 查看模式:显示模拟更新
updateWindow.SetDebugMode(true);
}
updateWindow.Show();
if (e.IsFunctional)
{
// 功能模式:模拟更新进度
_ = SimulateUpdateProgress(updateWindow);
}
}
/// <summary>
/// 打开OOBE页面
/// </summary>
private void OnOpenOobeRequested(object? sender, OobeOpenEventArgs e)
{
var oobeWindow = new OobeWindow();
if (!e.IsFunctional)
{
// 查看模式:显示调试标记(通过标题)
oobeWindow.Title = "[调试模式] 欢迎使用阑山桌面";
}
oobeWindow.Show();
if (e.IsFunctional)
{
// 功能模式:等待用户点击后自动关闭
_ = SimulateOobeProgress(oobeWindow);
}
}
/// <summary>
/// 模拟OOBE流程
/// </summary>
private async Task SimulateOobeProgress(OobeWindow oobeWindow)
{
try
{
// 等待用户点击开始按钮
await oobeWindow.WaitForEnterAsync();
// 用户点击后窗口会自动关闭通过OobeWindow内部的动画和关闭逻辑
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DevDebugWindow] Error during OOBE simulation: {ex.Message}");
}
}
/// <summary>
/// 关闭窗口
/// </summary>
private void OnCloseRequested(object? sender, EventArgs e)
{
Close();
}
/// <summary>
/// 模拟启动画面进度
/// </summary>
private async Task SimulateSplashProgress(SplashWindow splashWindow)
{
var stages = new[] { "初始化", "检查更新", "加载组件", "启动应用" };
var reporter = (ISplashStageReporter)splashWindow;
for (int i = 0; i < stages.Length; i++)
{
reporter.ReportStage(stages[i], (i + 1) * 25);
await Task.Delay(500);
}
// 3秒后关闭
await Task.Delay(3000);
splashWindow.Close();
}
/// <summary>
/// 模拟更新进度
/// </summary>
private async Task SimulateUpdateProgress(UpdateWindow updateWindow)
{
var stages = new[] { "下载", "验证", "安装", "清理" };
foreach (var stage in stages)
{
updateWindow.Report(stage, $"正在{stage}...", Array.IndexOf(stages, stage) * 25 + 10);
await Task.Delay(800);
}
updateWindow.ReportComplete(true, null);
// 2秒后关闭
await Task.Delay(2000);
updateWindow.Close();
}
protected override void OnClosed(EventArgs e)
{
// 取消订阅事件
_viewModel.OpenSplashRequested -= OnOpenSplashRequested;
_viewModel.OpenErrorRequested -= OnOpenErrorRequested;
_viewModel.OpenUpdateRequested -= OnOpenUpdateRequested;
_viewModel.OpenOobeRequested -= OnOpenOobeRequested;
_viewModel.CloseRequested -= OnCloseRequested;
base.OnClosed(e);
}
}

View File

@@ -0,0 +1,107 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
x:DataType="views:ErrorDebugWindow"
Title="调试模式"
Width="420"
Height="320"
CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorDebugWindow />
</Design.DataContext>
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
<!-- 标题 -->
<TextBlock Grid.Row="0"
Text="调试设置"
FontSize="20"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Margin="0,0,0,16" />
<!-- 设置内容 -->
<StackPanel Grid.Row="1" Spacing="16">
<!-- 开发模式开关 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="开发模式"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="启用后自动扫描开发目录"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,2,0,0" />
</StackPanel>
<ToggleSwitch x:Name="DevModeToggle"
Grid.Column="1"
OnContent="开"
OffContent="关" />
</Grid>
</Border>
<!-- 应用路径选择 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="16,12">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0" Grid.Column="0"
Text="应用路径"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock x:Name="PathTextBlock"
Grid.Row="1" Grid.Column="0"
Text="未选择"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"
Margin="0,4,12,0" />
<Button x:Name="BrowseButton"
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
Content="浏览..."
VerticalAlignment="Center" />
</Grid>
</Border>
<!-- 提示信息 -->
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
Padding="12,10"
IsVisible="True">
<TextBlock Text="此功能仅供开发人员使用"
FontSize="12"
Foreground="{DynamicResource SystemFillColorCautionBrush}"
TextWrapping="Wrap" />
</Border>
</StackPanel>
<!-- 按钮区域 -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,16,0,0">
<Button x:Name="CancelButton"
Content="取消"
Width="80"
Height="32" />
<Button x:Name="OkButton"
Content="确定"
Width="80"
Height="32"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,172 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized) return;
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
UpdatePathDisplay(_selectedHostPath);
}
private void InitializeComponents()
{
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
{
devModeToggle.IsCheckedChanged += (s, e) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
{
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
{
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null) return;
var options = new FilePickerOpenOptions
{
Title = "选择阑山桌面主程序",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
{
Patterns = OperatingSystem.IsWindows()
? new[] { "*.exe" }
: new[] { "*" }
}
}
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count > 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
}
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
}
}
}

View File

@@ -0,0 +1,105 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="阑山桌面"
Width="520"
Height="280"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorWindow />
</Design.DataContext>
<!-- Fluent Design 风格对话框布局 -->
<Grid RowDefinitions="*,Auto">
<!-- 主内容区域:左侧图标 + 右侧文字 -->
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
<!-- 左侧:错误图标(可点击进入调试模式) -->
<Border x:Name="ErrorIconBorder"
Grid.Column="0"
Width="48"
Height="48"
Margin="0,4,16,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="24"
VerticalAlignment="Top">
<TextBlock Text="&#xEA39;"
FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 右侧:标题 + 内容 -->
<StackPanel Grid.Column="1" Spacing="8">
<!-- 标题 -->
<TextBlock x:Name="TitleText"
Text="启动失败"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"/>
<!-- 错误信息 -->
<TextBlock x:Name="ErrorMessageText"
Text="找不到阑山桌面应用程序。"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="20"/>
<!-- 建议信息 -->
<TextBlock x:Name="SuggestionText"
Text="请确保应用程序已正确安装,或尝试重新安装。"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
LineHeight="18"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="24,16">
<Grid ColumnDefinitions="*,Auto">
<Button x:Name="OpenLogButton"
Grid.Column="0"
Content="打开日志"
Width="100"
Height="32"
FontSize="13"
HorizontalAlignment="Left"/>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="ExitButton"
Content="退出"
Width="80"
Height="32"
FontSize="13"/>
<Button x:Name="RetryButton"
Content="重试"
Width="80"
Height="32"
FontSize="13"
Theme="{DynamicResource AccentButtonTheme}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,542 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using LanMountainDesktop.Launcher.Services;
using System.Diagnostics;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
/// </summary>
public partial class ErrorWindow : Window
{
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
private int _iconClickCount = 0;
private const int DebugModeClickThreshold = 5;
private bool _isDebugMode = false;
private string? _customHostPath;
private bool _devModeEnabled;
public ErrorWindow()
{
AvaloniaXamlLoader.Load(this);
// 先加载保存的状态
_devModeEnabled = LoadDevModeStateInternal();
_customHostPath = LoadCustomHostPathInternal();
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件 - 视觉树已准备好
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
InitializeComponents();
}
/// <summary>
/// 窗口打开事件
/// </summary>
private void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[ErrorWindow] Window opened and visible");
}
private void InitializeComponents()
{
Console.WriteLine("[ErrorWindow] Initializing components...");
// 错误图标点击事件(进入调试模式 - 隐藏功能)
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
if (errorIconBorder is not null)
{
errorIconBorder.PointerPressed += OnErrorIconClick;
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
}
// 按钮事件
var retryButton = this.FindControl<Button>("RetryButton");
var exitButton = this.FindControl<Button>("ExitButton");
var openLogButton = this.FindControl<Button>("OpenLogButton");
if (retryButton is not null)
{
retryButton.Click += OnRetryClick;
Console.WriteLine("[ErrorWindow] RetryButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
}
if (exitButton is not null)
{
exitButton.Click += OnExitClick;
Console.WriteLine("[ErrorWindow] ExitButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
}
if (openLogButton is not null)
{
openLogButton.Click += OnOpenLogClick;
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
}
Console.WriteLine("[ErrorWindow] Components initialization completed");
}
/// <summary>
/// 设置错误消息
/// </summary>
public void SetErrorMessage(string message)
{
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
if (errorText is not null)
{
errorText.Text = message;
}
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
_isDebugMode = isDebugMode;
var titleText = this.FindControl<TextBlock>("TitleText");
if (titleText is not null && isDebugMode)
{
titleText.Text = "[调试模式] 错误页面";
}
}
/// <summary>
/// 获取用户选择的主程序路径
/// </summary>
public string? GetCustomHostPath() => _customHostPath;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled() => _devModeEnabled;
/// <summary>
/// 等待用户选择
/// </summary>
public Task<ErrorWindowResult> WaitForChoiceAsync()
{
return _completionSource.Task;
}
/// <summary>
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
/// </summary>
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
_iconClickCount++;
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
{
EnterDebugMode();
}
}
/// <summary>
/// 进入调试模式 - 显示调试窗口
/// </summary>
private async void EnterDebugMode()
{
_isDebugMode = true;
// 创建并显示调试窗口
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
// 订阅调试窗口关闭事件
debugWindow.Closed += (s, e) =>
{
// 更新状态
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
// 保存开发模式状态和自定义路径
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
// 如果启用了开发模式且没有选择路径,自动扫描
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
{
ScanDevPaths();
// 扫描到路径后也保存
if (!string.IsNullOrEmpty(_customHostPath))
{
SaveCustomHostPathInternal(_customHostPath);
}
}
};
await debugWindow.ShowDialog(this);
}
/// <summary>
/// 扫描开发路径
/// </summary>
private void ScanDevPaths()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var possiblePaths = new[]
{
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
_customHostPath = path;
break;
}
}
}
/// <summary>
/// 获取配置存储的基础目录
/// </summary>
private static string GetConfigBaseDirectory()
{
try
{
// 优先使用 LocalApplicationData用户状态
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(appData))
{
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
return configDir;
}
}
catch
{
// LocalApplicationData 不可用,回退到 Launcher 所在目录
}
// 回退方案:使用 Launcher 所在目录
try
{
var launcherDir = AppContext.BaseDirectory;
var configDir = Path.Combine(launcherDir, ".launcher");
return configDir;
}
catch
{
// 最后的兜底:使用当前目录
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
/// <summary>
/// 确保配置目录存在
/// </summary>
private static bool EnsureConfigDirectory(string dirPath)
{
try
{
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
}
return true;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
return false;
}
}
/// <summary>
/// 保存开发模式状态(内部方法)
/// </summary>
private static void SaveDevModeStateInternal(bool enabled)
{
try
{
var configDir = GetConfigBaseDirectory();
if (!EnsureConfigDirectory(configDir))
{
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
return;
}
var devModeFile = Path.Combine(configDir, "devmode.config");
File.WriteAllText(devModeFile, enabled ? "1" : "0");
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
}
}
/// <summary>
/// 加载开发模式状态(内部方法)
/// </summary>
private static bool LoadDevModeStateInternal()
{
try
{
var configDir = GetConfigBaseDirectory();
var devModeFile = Path.Combine(configDir, "devmode.config");
if (File.Exists(devModeFile))
{
var content = File.ReadAllText(devModeFile).Trim();
var enabled = content == "1";
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
return enabled;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
}
return false;
}
/// <summary>
/// 保存自定义主程序路径(内部方法)
/// </summary>
private static void SaveCustomHostPathInternal(string? path)
{
try
{
var configDir = GetConfigBaseDirectory();
if (!EnsureConfigDirectory(configDir))
{
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
return;
}
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
File.WriteAllText(hostPathFile, path ?? string.Empty);
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
}
}
/// <summary>
/// 加载自定义主程序路径(内部方法)
/// </summary>
private static string? LoadCustomHostPathInternal()
{
try
{
var configDir = GetConfigBaseDirectory();
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
if (File.Exists(hostPathFile))
{
var content = File.ReadAllText(hostPathFile).Trim();
// 验证路径是否仍然有效
if (!string.IsNullOrEmpty(content) && File.Exists(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
return content;
}
// 路径已失效,清理配置文件
if (!string.IsNullOrEmpty(content))
{
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
try
{
File.Delete(hostPathFile);
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
}
catch (Exception clearEx)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
}
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
}
return null;
}
/// <summary>
/// 检查是否启用了开发模式(静态方法,启动时调用)
/// </summary>
public static bool CheckDevModeEnabled()
{
return LoadDevModeStateInternal();
}
/// <summary>
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
/// </summary>
public static string? GetSavedCustomHostPath()
{
return LoadCustomHostPathInternal();
}
private void OnRetryClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Retry);
}
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completionSource.TrySetResult(ErrorWindowResult.Exit);
}
/// <summary>
/// 打开日志文件
/// </summary>
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
{
try
{
var logFilePath = Logger.GetLogFilePath();
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
{
// 如果没有日志文件,打开日志目录
var logDir = Path.GetDirectoryName(logFilePath);
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
{
OpenFolder(logDir);
}
else
{
// 尝试打开配置目录
var configDir = GetConfigBaseDirectory();
if (Directory.Exists(configDir))
{
OpenFolder(configDir);
}
else
{
Console.WriteLine("[ErrorWindow] No log file or directory available");
}
}
return;
}
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
OpenFile(logFilePath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
}
}
/// <summary>
/// 打开文件
/// </summary>
private static void OpenFile(string filePath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{filePath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", filePath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", filePath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
}
}
/// <summary>
/// 打开文件夹
/// </summary>
private static void OpenFolder(string folderPath)
{
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"\"{folderPath}\"",
UseShellExecute = true
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start("open", folderPath);
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", folderPath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
}
}
}
/// <summary>
/// 错误窗口用户选择结果
/// </summary>
public enum ErrorWindowResult
{
/// <summary>
/// 重试
/// </summary>
Retry,
/// <summary>
/// 退出
/// </summary>
Exit
}

View File

@@ -0,0 +1,76 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
x:DataType="views:OobeWindow"
Title="欢迎使用阑山桌面"
Width="600"
Height="500"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid">
<!-- 主内容区域 -->
<Grid Margin="48" RowDefinitions="*,Auto">
<!-- 中央内容区域 -->
<StackPanel Grid.Row="0"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="24">
<!-- 顶部:完成状态勾号图标 -->
<Border Width="80"
Height="80"
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="40"
HorizontalAlignment="Center">
<ui:SymbolIcon Symbol="Accept"
FontSize="40"
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 中央:欢迎文字 -->
<StackPanel Spacing="8" HorizontalAlignment="Center">
<TextBlock Text="欢迎使用阑山桌面"
FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center" />
<TextBlock Text="你的桌面,不止一面"
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center" />
</StackPanel>
</StackPanel>
<!-- 底部:圆形开始按钮 -->
<Button Grid.Row="1"
x:Name="EnterButton"
HorizontalAlignment="Center"
Width="56"
Height="56"
Margin="0,0,0,16"
Theme="{DynamicResource AccentButtonTheme}"
CornerRadius="28">
<ui:SymbolIcon Symbol="Forward"
FontSize="24"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
</Button>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,197 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// OOBE首次使用体验窗口 - 欢迎页面
/// </summary>
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
private bool _isTransitioning = false;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
var enterButton = this.FindControl<Button>("EnterButton");
if (enterButton is not null)
{
enterButton.Click += OnEnterClick;
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
}
else
{
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
}
}
/// <summary>
/// 窗口打开事件 - 播放入场动画
/// </summary>
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
/// <summary>
/// 播放入场动画
/// </summary>
private async Task PlayEntranceAnimationAsync()
{
try
{
// 获取内容元素
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接返回
return;
}
// 创建淡入动画
var fadeInAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
// 创建向上滑动动画
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
// 应用动画
await fadeInAnimation.RunAsync(contentGrid);
await slideUpAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
}
}
/// <summary>
/// 等待用户点击开始按钮
/// </summary>
public Task WaitForEnterAsync() => _completionSource.Task;
/// <summary>
/// 进入按钮点击事件
/// </summary>
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
// 播放退出动画
await PlayExitAnimationAsync();
// 完成 OOBE
_completionSource.TrySetResult(true);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
_completionSource.TrySetResult(true);
}
}
/// <summary>
/// 播放退出动画
/// </summary>
private async Task PlayExitAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接延迟后返回
await Task.Delay(200);
return;
}
// 创建淡出动画
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
Easing = new CubicEaseIn(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(200)
}
}
};
await fadeOutAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Exit animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
x:DataType="views:SplashWindow"
Title="LanMountain Desktop"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:SplashWindow />
</Design.DataContext>
<Grid>
<!-- 左上角:应用名称 -->
<TextBlock x:Name="AppNameText"
Text="LanMountain Desktop"
FontSize="24"
FontWeight="SemiBold"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="24,24,0,0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<!-- 底部区域:进度条和状态 -->
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
<Border x:Name="VersionTextBorder"
Grid.Column="0"
Background="Transparent"
Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Bottom">
<TextBlock x:Name="VersionText"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
Text="1.0.0 (Administrate)" />
</Border>
<!-- 右下角:阶段文字 -->
<TextBlock x:Name="StatusText"
Grid.Column="1"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Opacity="0.8"
Text="Initializing..." />
</Grid>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,250 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 启动画面窗口 - 简洁设计
/// </summary>
public partial class SplashWindow : Window, ISplashStageReporter
{
private int _versionTextClickCount = 0;
private const int DebugModeClickThreshold = 5;
private bool _isDebugModeOpened = false;
public SplashWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再绑定事件
this.Loaded += OnWindowLoaded;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
// 绑定版本文本点击事件隐藏功能点击5次打开开发者界面
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
if (versionTextBorder is not null)
{
versionTextBorder.PointerPressed += OnVersionTextClick;
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
}
else
{
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
}
}
/// <summary>
/// 版本文本点击事件 - 连续点击5次打开开发者界面隐藏功能
/// </summary>
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
{
if (_isDebugModeOpened) return;
_versionTextClickCount++;
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
if (_versionTextClickCount >= DebugModeClickThreshold)
{
OpenDebugWindow();
}
}
/// <summary>
/// 打开开发者调试窗口
/// </summary>
private async void OpenDebugWindow()
{
_isDebugModeOpened = true;
Console.WriteLine("[SplashWindow] Opening debug window...");
try
{
// 加载保存的状态
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
{
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
// 订阅窗口关闭事件以保存状态
debugWindow.Closed += (s, e) =>
{
Console.WriteLine("[SplashWindow] Debug window closed");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};
await debugWindow.ShowDialog(this);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
}
}
/// <summary>
/// 更新进度和状态
/// </summary>
public void Report(string stage, string message)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
return;
}
// 更新状态文本
statusText.Text = message;
// 根据阶段更新进度
var progress = ResolveProgress(stage);
if (progress > 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progress;
}
else
{
progressIndicator.IsIndeterminate = true;
}
});
}
/// <summary>
/// 更新进度0-100
/// </summary>
public void UpdateProgress(int percent, string? message = null)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
return;
}
if (!string.IsNullOrEmpty(message))
{
statusText.Text = message;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(percent, 0, 100);
});
}
/// <summary>
/// 更新状态文本
/// </summary>
public void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
return;
}
statusText.Text = message;
});
}
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
public void ReportStage(string stage, int progress)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
return;
}
statusText.Text = stage;
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(progress, 0, 100);
});
}
/// <summary>
/// 设置版本和开发代号
/// </summary>
public void SetVersionInfo(string version, string codename)
{
Dispatcher.UIThread.Post(() =>
{
var versionText = this.FindControl<TextBlock>("VersionText");
if (versionText is null)
{
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
return;
}
versionText.Text = $"{version} ({codename})";
});
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
return;
}
if (isDebugMode)
{
statusText.Text = "[Debug Mode] Splash Preview";
}
});
}
/// <summary>
/// 根据阶段名称解析进度值
/// </summary>
private static int ResolveProgress(string stage)
{
return stage.ToLowerInvariant() switch
{
"initializing" => 10,
"update" => 30,
"plugins" => 50,
"launch" => 70,
"ready" => 100,
_ => 0
};
}
}

View File

@@ -0,0 +1,108 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.UpdateWindow"
x:DataType="views:UpdateWindow"
Title="阑山桌面 - 更新"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:UpdateWindow />
</Design.DataContext>
<Grid>
<!-- 顶部:应用名称和最小化按钮 -->
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
<TextBlock x:Name="TitleText"
Text="阑山桌面"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="Update"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
</Border>
</StackPanel>
<!-- 最小化按钮 -->
<Button x:Name="MinimizeButton"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0">
<TextBlock Text="&#xE921;"
FontSize="12"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Button>
</Grid>
<!-- 底部区域:进度条和状态 -->
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 第一行:左下角状态,右下角百分比 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 左下角:状态文字 -->
<TextBlock x:Name="StatusText"
Grid.Column="0"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Text="正在更新,请稍候..." />
<!-- 右下角:百分比 -->
<TextBlock x:Name="PercentText"
Grid.Column="1"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Text="0%" />
</Grid>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
IsIndeterminate="True"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,123 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
/// </summary>
public partial class UpdateWindow : Window
{
public UpdateWindow()
{
AvaloniaXamlLoader.Load(this);
InitializeEventHandlers();
}
/// <summary>
/// 初始化事件处理程序
/// </summary>
private void InitializeEventHandlers()
{
var minimizeButton = this.FindControl<Button>("MinimizeButton");
if (minimizeButton != null)
{
minimizeButton.Click += (s, e) =>
{
this.WindowState = WindowState.Minimized;
};
}
}
/// <summary>
/// 更新状态和进度
/// </summary>
public void Report(string stage, string message, int progressPercent = -1)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
var percentText = this.FindControl<TextBlock>("PercentText");
if (statusText is null || progressIndicator is null || percentText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in Report: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}, PercentText={percentText != null}");
return;
}
statusText.Text = message;
if (progressPercent >= 0)
{
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = progressPercent;
percentText.Text = $"{progressPercent}%";
}
else
{
progressIndicator.IsIndeterminate = true;
percentText.Text = "";
}
});
}
/// <summary>
/// 显示更新完成状态
/// </summary>
public void ReportComplete(bool success, string? errorMessage = null)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
var percentText = this.FindControl<TextBlock>("PercentText");
var titleText = this.FindControl<TextBlock>("TitleText");
if (statusText is null || progressIndicator is null || percentText is null || titleText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in ReportComplete");
return;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = 100;
percentText.Text = "100%";
if (success)
{
statusText.Text = "更新完成";
}
else
{
titleText.Text = "更新失败";
statusText.Text = errorMessage ?? "更新过程中发生错误";
}
});
}
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
var titleText = this.FindControl<TextBlock>("TitleText");
if (statusText is null || titleText is null)
{
Console.Error.WriteLine($"[UpdateWindow] Controls not found in SetDebugMode");
return;
}
if (isDebugMode)
{
titleText.Text = "[调试模式] 更新页面";
statusText.Text = "预览更新进度界面";
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,109 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 外观变更事件参数,当主题、圆角或其他外观属性变化时触发。
/// </summary>
public sealed class AppearanceChangedEvent : EventArgs
{
/// <summary>
/// 创建外观变更事件实例。
/// </summary>
/// <param name="snapshot">当前外观快照</param>
/// <param name="changedProperties">变更的属性集合</param>
public AppearanceChangedEvent(
PluginAppearanceSnapshot snapshot,
IReadOnlyCollection<AppearanceProperty> changedProperties)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(changedProperties);
Snapshot = snapshot;
ChangedProperties = changedProperties;
}
/// <summary>
/// 当前外观快照。
/// </summary>
public PluginAppearanceSnapshot Snapshot { get; }
/// <summary>
/// 变更的属性集合。
/// </summary>
public IReadOnlyCollection<AppearanceProperty> ChangedProperties { get; }
/// <summary>
/// 圆角是否发生变化。
/// </summary>
public bool CornerRadiusChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadius);
/// <summary>
/// 主题变体(亮色/暗色)是否发生变化。
/// </summary>
public bool ThemeVariantChanged => ChangedProperties.Contains(AppearanceProperty.ThemeVariant);
/// <summary>
/// 强调色是否发生变化。
/// </summary>
public bool AccentColorChanged => ChangedProperties.Contains(AppearanceProperty.AccentColor);
/// <summary>
/// 圆角风格是否发生变化。
/// </summary>
public bool CornerRadiusStyleChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadiusStyle);
/// <summary>
/// 检查指定属性是否发生变化。
/// </summary>
/// <param name="property">要检查的属性</param>
/// <returns>如果属性发生变化则返回 true</returns>
public bool HasChanged(AppearanceProperty property)
{
return ChangedProperties.Contains(property);
}
/// <summary>
/// 检查是否有任何外观属性发生变化。
/// </summary>
public bool HasAnyChanges => ChangedProperties.Count > 0;
}
/// <summary>
/// 可变更的外观属性枚举。
/// </summary>
public enum AppearanceProperty
{
/// <summary>
/// 圆角Token值发生变化。
/// </summary>
CornerRadius,
/// <summary>
/// 主题变体(亮色/暗色)发生变化。
/// </summary>
ThemeVariant,
/// <summary>
/// 强调色发生变化。
/// </summary>
AccentColor,
/// <summary>
/// 圆角风格Sharp/Balanced/Rounded/Open发生变化。
/// </summary>
CornerRadiusStyle,
/// <summary>
/// 壁纸发生变化。
/// </summary>
Wallpaper,
/// <summary>
/// 系统材质模式发生变化。
/// </summary>
SystemMaterialMode,
/// <summary>
/// 所有外观属性(用于批量更新)。
/// </summary>
All
}

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

@@ -1,10 +1,35 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观上下文接口,提供主题、圆角等外观资源的访问和变更通知。
/// </summary>
public interface IPluginAppearanceContext
{
/// <summary>
/// 当前外观快照。
/// </summary>
PluginAppearanceSnapshot Snapshot { get; }
/// <summary>
/// 外观变更事件。当主题、圆角或其他外观属性发生变化时触发。
/// </summary>
event EventHandler<AppearanceChangedEvent>? Changed;
/// <summary>
/// 解析带缩放的圆角半径。
/// </summary>
/// <param name="baseRadius">基础圆角半径</param>
/// <param name="minimum">最小值(可选)</param>
/// <param name="maximum">最大值(可选)</param>
/// <returns>解析后的圆角半径</returns>
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
/// <summary>
/// 根据预设解析圆角半径。
/// </summary>
/// <param name="preset">圆角预设</param>
/// <param name="minimum">最小值(可选)</param>
/// <param name="maximum">最大值(可选)</param>
/// <returns>解析后的圆角半径</returns>
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
}

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version of
the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.0.0</Version>
<Version>4.0.2</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
@@ -13,11 +13,16 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<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

@@ -1,44 +1,84 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观上下文实现,提供主题、圆角等外观资源的访问和变更通知。
/// </summary>
public sealed class PluginAppearanceContext : IPluginAppearanceContext
{
private PluginAppearanceSnapshot _snapshot;
/// <summary>
/// 创建插件外观上下文实例。
/// </summary>
/// <param name="snapshot">初始外观快照</param>
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
Snapshot = snapshot with
_snapshot = snapshot with
{
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
? "Unknown"
: snapshot.ThemeVariant.Trim()
};
}
public PluginAppearanceSnapshot Snapshot { get; }
/// <inheritdoc />
public PluginAppearanceSnapshot Snapshot => _snapshot;
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
/// <inheritdoc />
public event EventHandler<AppearanceChangedEvent>? Changed;
/// <summary>
/// 更新外观快照并触发变更事件。
/// 此方法由宿主调用,用于在主题、圆角等外观属性变化时通知插件。
/// </summary>
/// <param name="newSnapshot">新的外观快照</param>
/// <param name="changedProperties">变更的属性集合</param>
public void UpdateSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
{
var scale = Snapshot.GlobalCornerRadiusScale;
var scaled = Math.Max(0d, baseRadius) * scale;
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
return minimum.HasValue || maximum.HasValue
? Math.Clamp(scaled, scaledMin, scaledMax)
: scaled;
ArgumentNullException.ThrowIfNull(newSnapshot);
ArgumentNullException.ThrowIfNull(changedProperties);
_snapshot = newSnapshot with
{
ThemeVariant = string.IsNullOrWhiteSpace(newSnapshot.ThemeVariant)
? "Unknown"
: newSnapshot.ThemeVariant.Trim()
};
if (changedProperties.Count > 0)
{
Changed?.Invoke(this, new AppearanceChangedEvent(_snapshot, changedProperties));
}
}
/// <inheritdoc />
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{
var value = Math.Max(0d, baseRadius);
if (!minimum.HasValue && !maximum.HasValue)
{
return value;
}
var clampedMin = minimum ?? value;
var clampedMax = maximum ?? value;
return Math.Clamp(value, clampedMin, clampedMax);
}
/// <inheritdoc />
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
{
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
var resolved = Math.Max(0d, _snapshot.CornerRadiusTokens.Get(preset));
if (!minimum.HasValue && !maximum.HasValue)
{
return resolved;
}
var clampedMin = minimum ?? resolved;
var clampedMax = maximum ?? resolved;
var clampedMin = minimum ?? 0d;
var clampedMax = maximum ?? double.MaxValue;
if (clampedMin > clampedMax)
{
(clampedMin, clampedMax) = (clampedMax, clampedMin);

View File

@@ -0,0 +1,137 @@
using Avalonia;
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观辅助方法,提供统一的圆角和主题资源访问。
/// </summary>
public static class PluginAppearanceHelper
{
/// <summary>
/// 获取桌面组件主外壳圆角半径。
/// 这是组件最外层边框应该使用的圆角值,对应 DesignCornerRadiusComponent 资源。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>主外壳圆角半径(像素)</returns>
public static double GetShellCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
}
/// <summary>
/// 获取内部卡片圆角半径。
/// 用于组件内部的次级卡片、内容区块等。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>内部卡片圆角半径(像素)</returns>
public static double GetCardCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Sm);
}
/// <summary>
/// 获取控件圆角半径。
/// 用于按钮、输入框、标签等交互控件。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>控件圆角半径(像素)</returns>
public static double GetControlCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Xs);
}
/// <summary>
/// 获取徽章/标签圆角半径。
/// 用于小徽章、标签、角标等微元素。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>徽章圆角半径(像素)</returns>
public static double GetBadgeCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Micro);
}
/// <summary>
/// 获取中等面板圆角半径。
/// 用于悬浮菜单、小提示框、子面板等。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>中等面板圆角半径(像素)</returns>
public static double GetMediumPanelCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Md);
}
/// <summary>
/// 获取大面板圆角半径。
/// 用于对话框、设置面板等大型容器(非桌面组件)。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>大面板圆角半径(像素)</returns>
public static double GetLargePanelCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Lg);
}
/// <summary>
/// 将圆角预设转换为 Avalonia CornerRadius。
/// </summary>
/// <param name="context">外观上下文</param>
/// <param name="preset">圆角预设</param>
/// <returns>Avalonia CornerRadius 结构</returns>
public static CornerRadius ToCornerRadius(this IPluginAppearanceContext context, PluginCornerRadiusPreset preset)
{
ArgumentNullException.ThrowIfNull(context);
var radius = context.ResolveCornerRadius(preset);
return new CornerRadius(radius);
}
/// <summary>
/// 获取当前主题变体(亮色/暗色)。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>是否为暗色主题</returns>
public static bool IsDarkTheme(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return string.Equals(context.Snapshot.ThemeVariant, "Dark", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 获取当前主题变体字符串。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>主题变体字符串("Light" 或 "Dark"</returns>
public static string GetThemeVariant(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Snapshot.ThemeVariant;
}
}
/// <summary>
/// 内部元素层级,用于区分不同层级的圆角需求。
/// </summary>
public enum InnerElementLevel
{
/// <summary>
/// 内部卡片:使用 Sm token14px @ 1.0x
/// </summary>
Card,
/// <summary>
/// 交互控件:使用 Xs token12px @ 1.0x
/// </summary>
Control,
/// <summary>
/// 微元素徽章:使用 Micro token6px @ 1.0x
/// </summary>
Badge
}

View File

@@ -1,6 +1,5 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginAppearanceSnapshot(
double GlobalCornerRadiusScale,
PluginCornerRadiusTokens CornerRadiusTokens,
string ThemeVariant);

View File

@@ -52,8 +52,6 @@ public sealed class PluginDesktopComponentContext
public IPluginAppearanceContext Appearance { get; }
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
public IPluginSettingsService? PluginSettings { get; }

View File

@@ -72,14 +72,11 @@ public sealed class PluginDesktopComponentRegistration
var resolved = CornerRadiusResolver is not null
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
? appearance.ResolveScaledCornerRadius(
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
8,
18)
? appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component)
: appearance.ResolveCornerRadius(CornerRadiusPreset);
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
}
}

View File

@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "4.0.0";
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

@@ -28,6 +28,35 @@ public static class PluginServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers a plugin settings section with a custom AXAML view.
/// The host application will display <typeparamref name="TView"/> directly
/// in the settings window, allowing the plugin to use any Fluent Avalonia controls
/// and custom layouts — just like built-in settings pages.
/// </summary>
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI using AXAML.</typeparam>
public static IServiceCollection AddPluginSettingsSection<TView>(
this IServiceCollection services,
string id,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
where TView : SettingsPageBase
{
ArgumentNullException.ThrowIfNull(services);
var builder = new PluginSettingsSectionBuilder(
id,
titleLocalizationKey,
descriptionLocalizationKey,
iconKey,
sortOrder);
builder.SetCustomView<TView>();
services.AddSingleton(builder.Build());
return services;
}
public static IServiceCollection AddPluginDesktopComponent<TControl>(
this IServiceCollection services,
PluginDesktopComponentOptions options)

View File

@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionBuilder
{
private readonly List<SettingsOptionDefinition> _options = [];
private Type? _customViewType;
internal PluginSettingsSectionBuilder(
string id,
@@ -30,8 +33,46 @@ public sealed class PluginSettingsSectionBuilder
public int SortOrder { get; }
public Type? CustomViewType => _customViewType;
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
/// <summary>
/// Sets a custom AXAML view for this settings section.
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
/// When a custom view is provided, the host application will use it directly
/// instead of generating a page from the declared options, allowing the plugin
/// to use any Fluent Avalonia controls and custom layouts.
/// </summary>
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI.</typeparam>
public PluginSettingsSectionBuilder SetCustomView<TView>() where TView : SettingsPageBase
{
_customViewType = typeof(TView);
return this;
}
/// <summary>
/// Sets a custom AXAML view for this settings section.
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
/// When a custom view is provided, the host application will use it directly
/// instead of generating a page from the declared options.
/// </summary>
/// <param name="viewType">A <see cref="SettingsPageBase"/> subclass type that defines the settings UI.</param>
public PluginSettingsSectionBuilder SetCustomView(Type viewType)
{
ArgumentNullException.ThrowIfNull(viewType);
if (!typeof(SettingsPageBase).IsAssignableFrom(viewType))
{
throw new ArgumentException(
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
nameof(viewType));
}
_customViewType = viewType;
return this;
}
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
{
ArgumentNullException.ThrowIfNull(option);
@@ -142,6 +183,7 @@ public sealed class PluginSettingsSectionBuilder
_options.ToArray(),
DescriptionLocalizationKey,
IconKey,
SortOrder);
SortOrder,
_customViewType);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
@@ -10,7 +11,8 @@ public sealed class PluginSettingsSectionRegistration
IReadOnlyList<SettingsOptionDefinition> options,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
int sortOrder = 0,
Type? customViewType = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
@@ -24,6 +26,15 @@ public sealed class PluginSettingsSectionRegistration
IconKey = iconKey.Trim();
SortOrder = sortOrder;
Options = options ?? [];
if (customViewType is not null && !typeof(SettingsPageBase).IsAssignableFrom(customViewType))
{
throw new ArgumentException(
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
nameof(customViewType));
}
CustomViewType = customViewType;
}
public string Id { get; }
@@ -37,4 +48,11 @@ public sealed class PluginSettingsSectionRegistration
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
/// <summary>
/// When set, the host application will instantiate this <see cref="SettingsPageBase"/> subclass
/// instead of generating a page from <see cref="Options"/>.
/// This allows plugins to provide fully custom AXAML views with any Fluent Avalonia controls.
/// </summary>
public Type? CustomViewType { get; }
}

View File

@@ -14,7 +14,7 @@ Official SDK package for LanMountainDesktop plugins.
```xml
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
```

View File

@@ -9,5 +9,6 @@ public enum SettingsPageCategory
PluginCatalog = 35,
[Obsolete("Use PluginCatalog instead.")]
PluginMarket = 35,
About = 40
About = 40,
Dev = 50
}

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

@@ -10,6 +10,38 @@ public sealed class Plugin : PluginBase
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
_ = context;
// ── Option 1: Declarative settings (simple key-value options) ──────────
// The host generates a settings page automatically from the declared options.
// Supported option types: Toggle, Text, Number, Select, Path, List.
//
// services.AddPluginSettingsSection(
// "my-plugin-settings",
// "My Plugin Settings",
// section => section
// .AddToggle("enable_feature", "Enable Feature", defaultValue: true)
// .AddNumber("refresh_interval", "Refresh Interval", defaultValue: 30, minimum: 5, maximum: 120),
// iconKey: "PuzzlePiece");
// ── Option 2: Custom AXAML view (full Fluent Avalonia controls) ────────
// Provide a SettingsPageBase subclass to use any Fluent Avalonia control
// (SettingsExpander, ColorPicker, Slider, etc.) — just like built-in pages.
//
// services.AddPluginSettingsSection<MyCustomSettingsPage>(
// "my-plugin-settings",
// "My Plugin Settings",
// iconKey: "PuzzlePiece");
//
// Or mix both: declare options AND set a custom view on the builder:
//
// services.AddPluginSettingsSection(
// "my-plugin-settings",
// "My Plugin Settings",
// section => section
// .SetCustomView<MyCustomSettingsPage>()
// .AddToggle("enable_feature", "Enable Feature"),
// iconKey: "PuzzlePiece");
_ = services;
}
}

View File

@@ -4,7 +4,7 @@
"description": "__PLUGIN_DESCRIPTION__",
"author": "__PLUGIN_AUTHOR__",
"version": "1.0.0",
"apiVersion": "4.0.0",
"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

@@ -1,242 +0,0 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
internal static class Program
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args)
{
var result = new HelperResult();
string? resultPath = null;
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
!parsedArgs.TryGetValue("result", out resultPath) ||
string.IsNullOrWhiteSpace(sourcePath) ||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
string.IsNullOrWhiteSpace(resultPath))
{
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
}
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
resultPath = Path.GetFullPath(resultPath);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
result = new HelperResult
{
Success = true,
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
catch (Exception ex)
{
result = new HelperResult
{
Success = false,
ErrorMessage = ex.Message
};
}
if (!string.IsNullOrWhiteSpace(resultPath))
{
var resultDirectory = Path.GetDirectoryName(resultPath);
if (!string.IsNullOrWhiteSpace(resultDirectory))
{
Directory.CreateDirectory(resultDirectory);
}
await File.WriteAllTextAsync(
resultPath,
JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
}),
Encoding.UTF8);
}
return result.Success ? 0 : 1;
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
DeleteFileWithRetry(existingPackagePath);
}
catch
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PluginSdkInfo.PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private sealed class HelperResult
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ManifestId { get; init; }
public string? ManifestName { get; init; }
public string? ErrorMessage { get; init; }
}
}

View File

@@ -2,17 +2,69 @@ namespace LanMountainDesktop.Settings.Core;
public static class GlobalAppearanceSettings
{
public const string CornerRadiusStyleSharp = "Sharp";
public const string CornerRadiusStyleBalanced = "Balanced";
public const string CornerRadiusStyleRounded = "Rounded";
public const string CornerRadiusStyleOpen = "Open";
public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced;
/// <summary>
/// Kept for backward compatibility during settings migration.
/// New code should not reference this constant.
/// </summary>
public const double DefaultCornerRadiusScale = 1.0;
public const double MinimumCornerRadiusScale = 0.0;
public const double MaximumCornerRadiusScale = 2.50;
public static double NormalizeCornerRadiusScale(double value)
public static string NormalizeCornerRadiusStyle(string? value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
if (string.IsNullOrWhiteSpace(value))
{
return DefaultCornerRadiusScale;
return DefaultCornerRadiusStyle;
}
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
var trimmed = value.Trim();
if (string.Equals(trimmed, CornerRadiusStyleSharp, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleSharp;
}
if (string.Equals(trimmed, CornerRadiusStyleBalanced, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleBalanced;
}
if (string.Equals(trimmed, CornerRadiusStyleRounded, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleRounded;
}
if (string.Equals(trimmed, CornerRadiusStyleOpen, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleOpen;
}
return DefaultCornerRadiusStyle;
}
public static readonly IReadOnlyList<string> AllCornerRadiusStyles =
[
CornerRadiusStyleSharp,
CornerRadiusStyleBalanced,
CornerRadiusStyleRounded,
CornerRadiusStyleOpen
];
/// <summary>
/// Backward compatibility: map previous scale values to the closest style.
/// </summary>
public static string MigrateScaleToStyle(double scale)
{
return scale switch
{
<= 0.60 => CornerRadiusStyleSharp,
<= 1.20 => CornerRadiusStyleBalanced,
<= 1.70 => CornerRadiusStyleRounded,
_ => CornerRadiusStyleOpen
};
}
}

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version of
the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -13,6 +13,8 @@
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />

View File

@@ -0,0 +1,22 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 应用版本信息
/// </summary>
public record AppVersionInfo
{
/// <summary>
/// 版本号,如 "1.0.0"
/// </summary>
public string Version { get; init; } = "0.0.0";
/// <summary>
/// 开发代号,如 "Administrate"
/// </summary>
public string Codename { get; init; } = "Unknown";
/// <summary>
/// 完整版本字符串,如 "1.0.0 (Administrate)"
/// </summary>
public string FullVersionText => $"{Version} ({Codename})";
}

View File

@@ -0,0 +1,89 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 启动阶段枚举
/// </summary>
public enum StartupStage
{
/// <summary>
/// 初始化中
/// </summary>
Initializing,
/// <summary>
/// 加载设置中
/// </summary>
LoadingSettings,
/// <summary>
/// 加载插件中
/// </summary>
LoadingPlugins,
/// <summary>
/// 初始化界面中
/// </summary>
InitializingUI,
/// <summary>
/// 就绪
/// </summary>
Ready
}
/// <summary>
/// 启动进度消息
/// </summary>
public record StartupProgressMessage
{
/// <summary>
/// 当前阶段
/// </summary>
public StartupStage Stage { get; init; }
/// <summary>
/// 进度百分比 (0-100)
/// </summary>
public int ProgressPercent { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Launcher IPC 常量
/// </summary>
public static class LauncherIpcConstants
{
/// <summary>
/// 命名管道名称
/// </summary>
public const string PipeName = "LanMountainDesktop_Launcher";
/// <summary>
/// Launcher 进程 ID 环境变量
/// </summary>
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
/// <summary>
/// 包根目录环境变量
/// </summary>
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
/// <summary>
/// 版本环境变量
/// </summary>
public const string VersionEnvVar = "LMD_VERSION";
/// <summary>
/// 开发代号环境变量
/// </summary>
public const string CodenameEnvVar = "LMD_CODENAME";
}

View File

@@ -11,19 +11,19 @@ namespace LanMountainDesktop.Tests;
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
{
[Theory]
[InlineData(80d, 0d)]
[InlineData(120d, 1d)]
[InlineData(160d, 2.5d)]
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
[InlineData(80d, "Sharp")]
[InlineData(120d, "Balanced")]
[InlineData(160d, "Rounded")]
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, string style)
{
var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
var expected = AppearanceCornerRadiusTokenFactory.Create(style).Component.TopLeft;
foreach (var descriptor in registry.GetDesktopComponents())
{
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, style));
Assert.Equal(expected, resolved, 3);
}
}
@@ -31,13 +31,12 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
private static ComponentChromeContext CreateChromeContext(
string componentId,
double cellSize,
double globalScale)
string style)
{
return new ComponentChromeContext(
componentId,
null,
cellSize,
globalScale,
AppearanceCornerRadiusTokenFactory.Create(globalScale));
AppearanceCornerRadiusTokenFactory.Create(style));
}
}

View File

@@ -1,93 +0,0 @@
using Avalonia;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class CornerRadiusScaleTests
{
[Theory]
[InlineData(-1d, 0d)]
[InlineData(0d, 0d)]
[InlineData(0.33d, 0.33d)]
[InlineData(1.234d, 1.234d)]
[InlineData(2.5d, 2.5d)]
[InlineData(3d, 2.5d)]
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
{
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
}
[Fact]
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
{
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
3);
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
3);
}
[Fact]
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
{
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 0d,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8))),
ThemeVariant: "Unknown"));
var context = new PluginDesktopComponentContext(
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
"C:\\Plugins\\plugin.id",
"C:\\Data\\plugin.id",
new NullServiceProvider(),
new Dictionary<string, object?>(),
"component-1",
null,
96d,
appearanceContext);
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
}
[Fact]
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
{
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 2d,
CornerRadiusTokens: new PluginCornerRadiusTokens(
Micro: 12d,
Xs: 20d,
Sm: 28d,
Md: 36d,
Lg: 48d,
Xl: 60d,
Island: 72d,
Component: 16d),
ThemeVariant: "Light"));
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

@@ -0,0 +1,76 @@
using Avalonia;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class CornerRadiusStyleTests
{
[Theory]
[InlineData("Sharp", "Sharp")]
[InlineData("Balanced", "Balanced")]
[InlineData("Rounded", "Rounded")]
[InlineData("Open", "Open")]
[InlineData("Unknown", "Balanced")]
[InlineData(null, "Balanced")]
public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected)
{
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input));
}
[Fact]
public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues()
{
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
CornerRadiusTokens: new PluginCornerRadiusTokens(
Micro: 6d,
Xs: 12d,
Sm: 14d,
Md: 20d,
Lg: 28d,
Xl: 32d,
Island: 36d,
Component: 24d),
ThemeVariant: "Light"));
// Preset resolution should return fixed values from tokens
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(15d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(18d, context.ResolveScaledCornerRadius(18d), 3);
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
}
[Fact]
public void PluginDesktopComponentContext_ProvidesDirectTokenAccess()
{
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
ThemeVariant: "Dark"));
var context = new PluginDesktopComponentContext(
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
"C:\\Plugins\\plugin.id",
"C:\\Data\\plugin.id",
new NullServiceProvider(),
new Dictionary<string, object?>(),
"component-1",
null,
96d,
appearanceContext);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d), 3);
// When min/max specified, value is clamped
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
// Component token access
Assert.Equal(24d, context.CornerRadiusTokens.Component, 3);
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Tests;
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
{
[Fact]
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
public void LegacyCellSizeResolver_ReturnsUnscaledFixedValue()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
@@ -19,41 +19,42 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
// Previously: (120 * 0.30) * 2.0 = 72.0
// Now: (120 * 0.30) = 36.0 (No scale applied automatically by the wrapper)
var resolved = resolver(CreateChromeContext(cellSize: 120));
Assert.Equal(72.0, resolved, 3);
Assert.Equal(36.0, resolved, 3);
}
[Fact]
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
public void ChromeContextResolver_UsesTokenValue()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
displayNameLocalizationKey: null,
controlFactory: _ => new Border(),
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
cornerRadiusResolver: chromeContext => chromeContext.CornerRadiusTokens.Component.TopLeft);
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
var resolved = resolver(CreateChromeContext(cellSize: 50));
Assert.Equal(52.5, resolved, 3);
Assert.Equal(24.0, resolved, 3);
}
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
private static ComponentChromeContext CreateChromeContext(double cellSize)
{
return new ComponentChromeContext(
ComponentId: "test.component",
PlacementId: null,
CellSize: cellSize,
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8)));
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24)));
}
}

View File

@@ -48,26 +48,27 @@ public sealed class InfoRecommendationHostCornerRadiusTests
registry.TryGetDescriptor(componentId, out var descriptor),
$"Missing runtime registration for '{componentId}'.");
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d));
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d));
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
var sharp = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Sharp"));
var balanced = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Balanced"));
var rounded = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Rounded"));
var open = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Open"));
Assert.Equal(0d, zero, 3);
Assert.Equal(18d, unit, 3);
Assert.Equal(45d, max, 3);
Assert.True(zero <= unit && unit <= max);
// All info widgets should resolve to the Component token in the new system
Assert.Equal(20d, sharp, 3);
Assert.Equal(24d, balanced, 3);
Assert.Equal(28d, rounded, 3);
Assert.Equal(32d, open, 3);
}
private static ComponentChromeContext CreateChromeContext(
string componentId,
double cellSize,
double globalScale)
string style)
{
return new ComponentChromeContext(
componentId,
null,
cellSize,
globalScale,
AppearanceCornerRadiusTokenFactory.Create(globalScale));
AppearanceCornerRadiusTokenFactory.Create(style));
}
}

View File

@@ -0,0 +1,113 @@
using System;
using System.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class StudyAnalyticsServiceTests
{
[Fact]
public void SnapshotUpdated_UsesUiPublishThrottle()
{
using var recorder = new FakeAudioRecorderService();
using var service = new StudyAnalyticsService(recorder);
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
var updateCount = 0;
service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount);
Assert.True(service.StartOrResumeMonitoring());
Thread.Sleep(280);
Assert.True(service.PauseMonitoring());
var totalUpdates = Volatile.Read(ref updateCount);
Assert.InRange(totalUpdates, 2, 6);
}
[Fact]
public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive()
{
using var recorder = new FakeAudioRecorderService();
using var service = new StudyAnalyticsService(recorder);
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
using var firstUpdate = new ManualResetEventSlim(false);
service.SnapshotUpdated += (_, args) =>
{
if (args.Snapshot.RealtimeBuffer.Count > 0)
{
firstUpdate.Set();
}
};
Assert.True(service.StartOrResumeMonitoring());
Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2)));
Assert.True(service.PauseMonitoring());
var firstSnapshot = service.GetSnapshot();
var secondSnapshot = service.GetSnapshot();
Assert.NotEmpty(firstSnapshot.RealtimeBuffer);
Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer);
}
private sealed class FakeAudioRecorderService : IAudioRecorderService
{
private readonly object _syncRoot = new();
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready;
public AudioRecorderSnapshot GetSnapshot()
{
lock (_syncRoot)
{
return new AudioRecorderSnapshot(
State: _state,
Duration: TimeSpan.Zero,
InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0,
LastSavedFilePath: string.Empty,
LastError: string.Empty);
}
}
public bool StartOrResume()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Recording;
return true;
}
}
public bool Pause()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Paused;
return true;
}
}
public string? StopAndSave(string? outputPath = null)
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Ready;
return outputPath;
}
}
public void Discard()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Ready;
}
}
public void Dispose()
{
}
}
}

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

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