Compare commits

..

43 Commits

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

View File

@@ -1,4 +1,4 @@
name: Build
name: Build
on:
push:
@@ -10,6 +10,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
build-windows:
@@ -63,7 +64,8 @@ jobs:
sudo apt-get install -y \
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0
- name: Setup .NET
uses: actions/setup-dotnet@v4

View File

@@ -1,4 +1,4 @@
name: Quality Check
name: Quality Check
on:
pull_request:
@@ -9,6 +9,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
analyze:

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -19,6 +19,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
prepare:
@@ -29,7 +30,7 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
steps:
- name: Get release info
id: version
@@ -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,114 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:SelfContained=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 }}
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$launcherPublishDir = "publish/launcher-win-$arch"
if ($selfContained) {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained `
-r win-$arch `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
} else {
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:DebugType=none `
-p:DebugSymbols=false
}
Write-Host "Launcher published to: $launcherPublishDir"
shell: pwsh
- name: Publish Main App
run: |
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
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 +218,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 +268,11 @@ jobs:
Write-Error "Inno Setup compiler not found."
exit 1
}
Write-Host "Found Inno Setup at: $isccPath"
# Build installer with iscc.exe
Write-Host "Building installer for Windows $arch with version $version..."
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
$installerScript = (Resolve-Path $installerScript).Path
@@ -187,33 +282,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 +518,7 @@ jobs:
needs: prepare
runs-on: ubuntu-latest
name: Build_Linux
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -256,11 +552,24 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
-o ./publish/linux-x64-app \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
@@ -274,6 +583,34 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Restructure for Launcher
run: |
version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64"
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "$publishDir/$appDir/.current"
echo "New directory structure:"
find "$publishDir" -maxdepth 2 | head -50
rm -rf "$launcherDir"
- name: Package as DEB
run: |
version="${{ needs.prepare.outputs.version }}"
@@ -283,28 +620,24 @@ jobs:
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
# Create DEB package structure
mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
mkdir -p "build-deb/usr/share/pixmaps"
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
# Copy application files
cp -r "$source"/* "build-deb/usr/local/bin/"
# Verify copy was successful
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
echo "DEB package contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
@@ -317,7 +650,7 @@ jobs:
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
@@ -334,8 +667,7 @@ jobs:
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
} > "build-deb/DEBIAN/postinst"
# Create control file (NOTE: No leading spaces in control file)
{
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
@@ -344,15 +676,13 @@ jobs:
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
} > "build-deb/DEBIAN/control"
# Set proper permissions
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
# Create DEB file
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb"
@@ -376,7 +706,7 @@ jobs:
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -401,11 +731,24 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false \
-p:DebugType=none \
-p:DebugSymbols=false
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }} \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
@@ -419,45 +762,57 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DMG
- name: Restructure and Package as DMG
run: |
version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}"
source="publish/macos-$arch"
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "${app_name}.app/Contents/MacOS"
appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
if [ -d "$appSourceDir" ]; then
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
else
echo "Error: Main app source directory not found: $appSourceDir"
exit 1
fi
# Create app bundle structure
mkdir -p "${app_name}.app/Contents/MacOS"
if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"
# Copy application files
cp -r "$source"/* "${app_name}.app/Contents/MacOS/"
# Verify copy was successful
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
echo "App bundle contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
fi
# Create Info.plist
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
printf '%s\n' '<plist version="1.0">'
printf '%s\n' '<dict>'
printf '%s\n' ' <key>CFBundleExecutable</key>'
printf '%s\n' ' <string>LanMountainDesktop</string>'
printf '%s\n' ' <string>LanMountainDesktop.Launcher</string>'
printf '%s\n' ' <key>CFBundleName</key>'
printf '%s\n' ' <string>LanMountain Desktop</string>'
printf '%s\n' ' <key>CFBundleVersion</key>'
@@ -471,11 +826,10 @@ jobs:
printf '%s\n' '</dict>'
printf '%s\n' '</plist>'
} > "${app_name}.app/Contents/Info.plist"
# Create DMG
mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
@@ -483,8 +837,7 @@ jobs:
echo "Error: Failed to create DMG"
exit 1
fi
# Cleanup
rm -rf dmg-temp "${app_name}.app"
- name: Upload
@@ -500,7 +853,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -510,29 +863,32 @@ jobs:
- name: List artifacts structure
run: |
echo "🔍 Artifact directory structure:"
echo "Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "📊 Files found:"
echo "Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "📁 Full tree:"
echo "Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
- name: Flatten artifacts for release
run: |
echo "📦 Organizing artifacts..."
echo "Organizing artifacts..."
mkdir -p release-files
# Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "⚠️ No files found in release-files"
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
echo ""
echo "📋 Total files:"
echo "Total files:"
file_count=$(find release-files -type f | wc -l)
echo "$file_count"
if [ "$file_count" -eq 0 ]; then
echo "Error: No installer/package files found for release"
echo "Error: No release files found"
exit 1
fi
@@ -548,19 +904,26 @@ 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)
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,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,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Launcher.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
public string Command { get; }
public string SubCommand { get; }
public IReadOnlyDictionary<string, string> Options { get; }
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
{
Command = command;
SubCommand = subCommand;
Options = options;
}
public static CommandContext FromArgs(string[] args)
{
var options = ParseOptions(args);
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
? args[0]
: "launch";
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options);
}
public string? GetOption(string key)
{
return Options.TryGetValue(key, out var value) ? value : null;
}
public int GetIntOption(string key, int fallback)
{
var raw = GetOption(key);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];
continue;
}
values[key] = "true";
}
return values;
}
}

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- Embed public-key.pem and copy to .launcher/update/ in output directory -->
<ItemGroup>
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

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

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Launcher.Models;
internal sealed class LauncherResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("stage")]
public string Stage { get; init; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; init; } = "ok";
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("currentVersion")]
public string? CurrentVersion { get; init; }
[JsonPropertyName("targetVersion")]
public string? TargetVersion { get; init; }
[JsonPropertyName("rolledBackTo")]
public string? RolledBackTo { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, string> Details { get; init; } = [];
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; }
[JsonPropertyName("manifestId")]
public string? ManifestId { get; init; }
[JsonPropertyName("manifestName")]
public string? ManifestName { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,24 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// GitHub Release 信息
/// </summary>
public sealed class ReleaseInfo
{
public required string TagName { get; init; }
public required string Name { get; init; }
public required bool Prerelease { get; init; }
public required DateTime PublishedAt { get; init; }
public required List<ReleaseAsset> Assets { get; init; }
public string? Body { get; init; }
}
/// <summary>
/// Release 资源文件
/// </summary>
public sealed class ReleaseAsset
{
public required string Name { get; init; }
public required string BrowserDownloadUrl { get; init; }
public required long Size { get; init; }
}

View File

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

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新检查结果
/// </summary>
public sealed class UpdateCheckResult
{
public bool HasUpdate { get; init; }
public string? LatestVersion { get; init; }
public string? CurrentVersion { get; init; }
public ReleaseInfo? Release { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,55 @@
namespace LanMountainDesktop.Launcher.Models;
internal sealed class SignedFileMap
{
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public List<UpdateFileEntry> Files { get; set; } = [];
}
internal sealed class UpdateFileEntry
{
public string Path { get; set; } = string.Empty;
public string? ArchivePath { get; set; }
public string Action { get; set; } = "replace";
public string? Sha256 { get; set; }
}
internal sealed class SnapshotMetadata
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string SourceDirectory { get; set; } = string.Empty;
public string? TargetDirectory { get; set; }
public string Status { get; set; } = "pending";
}
internal sealed class UpdateApplyResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public string? FromVersion { get; init; }
public string? ToVersion { get; init; }
public string? RolledBackTo { get; init; }
}

View File

@@ -0,0 +1 @@
MessageBox

View File

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

View File

@@ -0,0 +1,29 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Launcher (Launch Mode)": {
"commandName": "Project",
"commandLineArgs": "launch",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Update Check)": {
"commandName": "Project",
"commandLineArgs": "update check",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Plugin Install)": {
"commandName": "Project",
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,174 @@
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal static class Commands
{
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
{
var resultPath = context.GetOption("result");
LauncherResult result;
try
{
var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
result = installer.InstallPackage(source, pluginsDir);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "failed",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "command",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
default:
return new LauncherResult
{
Success = false,
Stage = "command",
Code = "unsupported_command",
Message = $"Unsupported command '{context.Command}'."
};
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.SubCommand.ToLowerInvariant())
{
case "install":
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginInstaller.InstallPackage(source, pluginsDir);
}
case "update":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
}
default:
return new LauncherResult
{
Success = false,
Stage = "plugin",
Code = "unsupported_subcommand",
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
};
}
}
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
{
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
var fullPath = Path.GetFullPath(resultPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}
public static string ResolveAppRoot(CommandContext context)
{
var configured = context.GetOption("app-root");
if (!string.IsNullOrWhiteSpace(configured))
{
return Path.GetFullPath(configured);
}
var baseDir = AppContext.BaseDirectory;
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
return File.Exists(parentHost) ? parent : baseDir;
}
}

View File

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

View File

@@ -0,0 +1,160 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeploymentLocator
{
private readonly string _appRoot;
public DeploymentLocator(string appRoot)
{
_appRoot = appRoot;
}
public string GetAppRoot() => _appRoot;
public string? FindCurrentDeploymentDirectory()
{
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
// 过滤掉无效的部署目录
var validCandidates = candidates
.Where(path =>
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
.ToList();
// 优先选择带 .current 标记的版本
var withMarkers = validCandidates
.Where(path => File.Exists(Path.Combine(path, ".current")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
if (withMarkers.Count > 0)
{
return withMarkers[0].Path;
}
// 如果没有 .current 标记,选择最新版本
var byVersion = validCandidates
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.ToList();
return byVersion.Count > 0 ? byVersion[0].Path : null;
}
public string? ResolveHostExecutablePath()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
return File.Exists(inParent) ? inParent : null;
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(deployment))
{
return "0.0.0";
}
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
public void CleanupDestroyedDeployments()
{
try
{
var candidates = Directory.Exists(_appRoot)
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
: [];
var destroyedDirs = candidates
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
foreach (var dir in destroyedDirs)
{
try
{
Directory.Delete(dir, recursive: true);
}
catch
{
// 忽略删除失败(可能文件被占用),下次启动再试
}
}
}
catch
{
// 忽略清理失败
}
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return null;
}
return segments[1];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,194 @@
using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator
{
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine;
private readonly UpdateCheckService _updateCheckService;
private readonly PluginInstallerService _pluginInstallerService;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
public LauncherFlowCoordinator(
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
UpdateEngineService updateEngine,
UpdateCheckService updateCheckService,
PluginInstallerService pluginInstallerService)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_updateCheckService = updateCheckService;
_pluginInstallerService = pluginInstallerService;
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
}
public async Task<LauncherResult> RunAsync()
{
try
{
// 清理待删除的旧版本
_deploymentLocator.CleanupDestroyedDeployments();
if (_oobeStateService.IsFirstRun())
{
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
}
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
var reporter = (ISplashStageReporter)splashWindow;
try
{
reporter.Report("silentUpdate", "update");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
return updateResult;
}
reporter.Report("pluginTasks", "plugins");
var pluginsDir = _context.GetOption("plugins-dir")
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
{
return queueResult;
}
reporter.Report("launchHost", "launch");
var hostResult = LaunchHost();
if (!hostResult.Success)
{
return hostResult;
}
return new LauncherResult
{
Success = true,
Stage = "exit",
Code = "ok",
Message = "Launcher completed successfully."
};
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
}
}
catch (Exception ex)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
}
private LauncherResult LaunchHost()
{
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
if (string.IsNullOrWhiteSpace(hostPath))
{
return new LauncherResult
{
Success = false,
Stage = "launchHost",
Code = "host_not_found",
Message = "LanMountainDesktop host executable not found."
};
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
EnsureExecutable(hostPath);
}
var processStartInfo = new ProcessStartInfo
{
FileName = hostPath,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
};
Process.Start(processStartInfo);
return new LauncherResult
{
Success = true,
Stage = "launchHost",
Code = "ok",
Message = "Host launched."
};
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
var mode = File.GetUnixFileMode(path);
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
File.SetUnixFileMode(path, mode);
}
catch
{
}
}
private sealed class WelcomeOobeStep : IOobeStep
{
private readonly OobeStateService _stateService;
public WelcomeOobeStep(OobeStateService stateService)
{
_stateService = stateService;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
var window = await Dispatcher.UIThread.InvokeAsync(() =>
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
return oobeWindow;
});
try
{
using var _ = cancellationToken.Register(() => window.Close());
await window.WaitForEnterAsync().ConfigureAwait(false);
_stateService.MarkCompleted();
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => window.Close());
}
}
}
}

View File

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

View File

@@ -1,10 +1,10 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Launcher.Models;
internal static class Program
namespace LanMountainDesktop.Launcher.Services;
internal sealed class PluginInstallerService
{
private static readonly TimeSpan[] RetryDelays =
[
@@ -13,103 +13,38 @@ internal static class Program
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args)
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
{
var result = new HelperResult();
string? resultPath = null;
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
try
if (!File.Exists(fullSourcePath))
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
!parsedArgs.TryGetValue("result", out resultPath) ||
string.IsNullOrWhiteSpace(sourcePath) ||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
string.IsNullOrWhiteSpace(resultPath))
{
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
}
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
resultPath = Path.GetFullPath(resultPath);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
result = new HelperResult
{
Success = true,
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
catch (Exception ex)
{
result = new HelperResult
{
Success = false,
ErrorMessage = ex.Message
};
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
if (!string.IsNullOrWhiteSpace(resultPath))
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
return new LauncherResult
{
var resultDirectory = Path.GetDirectoryName(resultPath);
if (!string.IsNullOrWhiteSpace(resultDirectory))
{
Directory.CreateDirectory(resultDirectory);
}
await File.WriteAllTextAsync(
resultPath,
JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
}),
Encoding.UTF8);
}
return result.Success ? 0 : 1;
Success = true,
Stage = "plugin.install",
Code = "ok",
Message = "Plugin installed.",
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
public PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
@@ -132,9 +67,12 @@ internal static class Program
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
@@ -154,11 +92,45 @@ internal static class Program
continue;
}
DeleteFileWithRetry(existingPackagePath);
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
File.Move(existingPackagePath, pendingPath);
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
}
@@ -187,7 +159,6 @@ internal static class Program
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
@@ -226,17 +197,4 @@ internal static class Program
? path
: path + Path.DirectorySeparatorChar;
}
private sealed class HelperResult
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ManifestId { get; init; }
public string? ManifestName { get; init; }
public string? ErrorMessage { get; init; }
}
}

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class PluginUpgradeQueueService
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private readonly PluginInstallerService _installerService;
public PluginUpgradeQueueService(PluginInstallerService installerService)
{
_installerService = installerService;
}
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
{
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
if (!File.Exists(pendingPath))
{
return new LauncherResult
{
Success = true,
Stage = "plugin.update",
Code = "noop",
Message = "No pending plugin upgrades."
};
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
foreach (var item in pending)
{
if (!item.IsValid())
{
failures.Add(item.PluginId);
continue;
}
try
{
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
succeeded.Add(item);
}
catch
{
failures.Add(item.PluginId);
}
}
var remaining = pending
.Except(succeeded)
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
.ToList();
if (remaining.Count == 0)
{
File.Delete(pendingPath);
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
{
WriteIndented = true
}));
}
return new LauncherResult
{
Success = failures.Count == 0,
Stage = "plugin.update",
Code = failures.Count == 0 ? "ok" : "partial_failed",
Message = failures.Count == 0
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
};
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}
}

View File

@@ -0,0 +1,168 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 更新检查服务 - 基于 GitHub Release API
/// </summary>
internal sealed class UpdateCheckService
{
private const string GitHubApiBase = "https://api.github.com";
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName)
{
_repoOwner = repoOwner;
_repoName = repoName;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
/// <summary>
/// 检查更新
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string currentVersion,
UpdateChannel channel,
CancellationToken cancellationToken = default)
{
try
{
var releases = await FetchReleasesAsync(cancellationToken);
// 根据频道过滤版本
var filteredReleases = channel == UpdateChannel.Stable
? releases.Where(r => !r.Prerelease).ToList()
: releases;
// 找到最新版本
var latestRelease = filteredReleases
.OrderByDescending(r => ParseVersion(r.TagName))
.FirstOrDefault();
if (latestRelease == null)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = "No releases found"
};
}
var latestVersion = ParseVersionString(latestRelease.TagName);
var current = ParseVersion(currentVersion);
var latest = ParseVersion(latestVersion);
return new UpdateCheckResult
{
HasUpdate = latest > current,
LatestVersion = latestVersion,
CurrentVersion = currentVersion,
Release = latestRelease
};
}
catch (Exception ex)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 获取所有 Release
/// </summary>
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
{
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
return releases?.Select(r => new ReleaseInfo
{
TagName = r.TagName ?? "",
Name = r.Name ?? "",
Prerelease = r.Prerelease,
PublishedAt = r.PublishedAt,
Body = r.Body,
Assets = r.Assets?.Select(a => new ReleaseAsset
{
Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
Size = a.Size
}).ToList() ?? []
}).ToList() ?? [];
}
/// <summary>
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
/// </summary>
private static string ParseVersionString(string tag)
{
return tag.TrimStart('v', 'V');
}
/// <summary>
/// 解析版本号
/// </summary>
private static Version ParseVersion(string versionString)
{
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
// GitHub API 响应模型
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}
}

View File

@@ -0,0 +1,512 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class UpdateEngineService
{
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string SnapshotsDirectoryName = "snapshots";
private const string SignedFileMapName = "files.json";
private const string SignatureFileName = "files.json.sig";
private const string ArchiveFileName = "update.zip";
private const string PublicKeyFileName = "public-key.pem";
private readonly DeploymentLocator _deploymentLocator;
private readonly string _appRoot;
private readonly string _launcherRoot;
private readonly string _incomingRoot;
private readonly string _snapshotsRoot;
public UpdateEngineService(DeploymentLocator deploymentLocator)
{
_deploymentLocator = deploymentLocator;
_appRoot = deploymentLocator.GetAppRoot();
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
}
public LauncherResult CheckPendingUpdate()
{
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
{
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "noop",
Message = "No pending update."
};
}
var fileMapText = File.ReadAllText(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
if (fileMap is null)
{
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
}
var verified = VerifySignature(fileMapPath, signaturePath);
if (!verified.Success)
{
return Failed("update.check", "signature_failed", verified.Message);
}
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending update is available.",
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
TargetVersion = fileMap.ToVersion
};
}
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
{
Directory.CreateDirectory(_incomingRoot);
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName);
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false))
await using (var output = File.Create(manifestPath))
{
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false))
await using (var output = File.Create(signaturePath))
{
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false))
await using (var output = File.Create(archivePath))
{
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
return new LauncherResult
{
Success = true,
Stage = "update.download",
Code = "ok",
Message = "Update downloaded."
};
}
public async Task<LauncherResult> ApplyPendingUpdateAsync()
{
Directory.CreateDirectory(_incomingRoot);
Directory.CreateDirectory(_snapshotsRoot);
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
{
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "noop",
Message = "No update payload found."
};
}
var verifyResult = VerifySignature(fileMapPath, signaturePath);
if (!verifyResult.Success)
{
return Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
if (fileMap is null || fileMap.Files.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
}
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
}
var currentVersion = _deploymentLocator.GetCurrentVersion();
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
{
return Failed(
"update.apply",
"version_mismatch",
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
}
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = currentVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
Status = "pending"
};
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
SaveSnapshot(snapshotPath, snapshot);
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
foreach (var file in fileMap.Files)
{
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
}
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
{
continue;
}
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
ActivateDeployment(currentDeployment, targetDeployment);
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
CleanupDestroyedDeployments();
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Updated to {targetVersion}.",
CurrentVersion = currentVersion,
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
TryRollbackOnFailure(snapshot);
snapshot.Status = "rolled_back";
SaveSnapshot(snapshotPath, snapshot);
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = "apply_failed",
Message = "Failed to apply update. Rolled back to previous version.",
ErrorMessage = ex.Message,
CurrentVersion = currentVersion,
RolledBackTo = currentVersion
};
}
finally
{
try
{
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
}
catch
{
}
}
}
public LauncherResult RollbackLatest()
{
if (!Directory.Exists(_snapshotsRoot))
{
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshotPath = Directory
.EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(File.GetCreationTimeUtc)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(snapshotPath))
{
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
}
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
}
ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
snapshot.Status = "manual_rollback";
SaveSnapshot(snapshotPath, snapshot);
return new LauncherResult
{
Success = true,
Stage = "update.rollback",
Code = "ok",
Message = $"Rolled back to {snapshot.SourceVersion}.",
RolledBackTo = snapshot.SourceVersion
};
}
public void CleanupDestroyedDeployments()
{
foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
{
if (!File.Exists(Path.Combine(dir, ".destroy")))
{
continue;
}
try
{
Directory.Delete(dir, true);
}
catch
{
}
}
}
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
{
var normalizedPath = NormalizeRelativePath(file.Path);
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
var targetPath = Path.Combine(targetDeployment, normalizedPath);
EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase))
{
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
EnsurePathWithinRoot(sourcePath, currentDeployment);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
}
File.Copy(sourcePath, targetPath, overwrite: true);
return;
}
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(extractRoot, archiveRelative);
EnsurePathWithinRoot(extractedPath, extractRoot);
if (!File.Exists(extractedPath))
{
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
}
File.Copy(extractedPath, targetPath, overwrite: true);
}
private void ActivateDeployment(string fromDeployment, string toDeployment)
{
var toCurrent = Path.Combine(toDeployment, ".current");
var fromCurrent = Path.Combine(fromDeployment, ".current");
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
var toPartial = Path.Combine(toDeployment, ".partial");
File.WriteAllText(toCurrent, string.Empty);
if (File.Exists(fromCurrent))
{
File.Delete(fromCurrent);
}
File.WriteAllText(fromDestroy, string.Empty);
if (File.Exists(toPartial))
{
File.Delete(toPartial);
}
}
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
{
try
{
if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory))
{
Directory.Delete(snapshot.TargetDirectory, true);
}
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
{
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
}
if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current")))
{
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
}
}
catch
{
}
}
private void CleanupIncomingArtifacts()
{
foreach (var path in new[]
{
Path.Combine(_incomingRoot, SignedFileMapName),
Path.Combine(_incomingRoot, SignatureFileName),
Path.Combine(_incomingRoot, ArchiveFileName)
})
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
}
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
{
if (!File.Exists(signaturePath))
{
return (false, "Missing files.json.sig.");
}
var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName);
if (!File.Exists(publicKeyPath))
{
return (false, $"Missing public key: {publicKeyPath}");
}
var jsonBytes = File.ReadAllBytes(fileMapPath);
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
if (string.IsNullOrWhiteSpace(signatureBase64))
{
return (false, "Signature is empty.");
}
byte[] signature;
try
{
signature = Convert.FromBase64String(signatureBase64);
}
catch (FormatException)
{
return (false, "Signature is not valid base64.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return isValid ? (true, "ok") : (false, "Signature verification failed.");
}
private static string NormalizeRelativePath(string path)
{
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
return normalized.TrimStart(Path.DirectorySeparatorChar);
}
private static void EnsurePathWithinRoot(string targetPath, string rootPath)
{
var fullTarget = Path.GetFullPath(targetPath);
var fullRoot = Path.GetFullPath(rootPath);
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
}
}
private static bool NeedsVerification(UpdateFileEntry file)
{
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(file.Sha256);
}
private static string ComputeSha256Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
{
WriteIndented = true
}));
}
private static LauncherResult Failed(string stage, string code, string message)
{
return new LauncherResult
{
Success = false,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = message
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

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

View File

@@ -67,6 +67,7 @@ public partial class App : Application
private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
@@ -148,6 +149,11 @@ public partial class App : Application
LinuxDesktopEntryInstaller.EnsureInstalled();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
{
FusedDesktopManagerServiceFactory.GetOrCreate().Initialize();
}
base.OnFrameworkInitializationCompleted();
}
@@ -218,12 +224,59 @@ public partial class App : Application
{
_ = sender;
_ = e;
if (_mainWindow is null)
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows())
{
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
return;
}
_detachedComponentLibraryWindowService.Open(_mainWindow);
// 切换进入编辑模式,隐藏常态零散的小部件
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
// 确保透明覆盖层窗口存在并显示
EnsureTransparentOverlayWindow();
// 打开融合桌面组件库窗口
Dispatcher.UIThread.Post(() =>
{
try
{
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Show();
}
var window = new FusedDesktopComponentLibraryWindow();
if (_transparentOverlayWindow is not null)
{
window.SetOverlayWindow(_transparentOverlayWindow);
}
// 当组件库关闭时,退出编辑态
window.Closed += (s, ev) =>
{
if (_transparentOverlayWindow is not null)
{
// 触发画布保存,并隐藏画布
_transparentOverlayWindow.SaveLayoutAndHide();
}
// 让管理器根据已存储的最新快照重建生成所有实体小组件
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
};
window.Show();
window.Activate();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
}
}, DispatcherPriority.Send);
}
private void DisableAvaloniaDataAnnotationValidation()
@@ -351,10 +404,7 @@ public partial class App : Application
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
if (_trayComponentLibraryMenuItem is not null)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
RefreshFusedDesktopMenuItemVisibility();
if (_trayRestartMenuItem is not null)
{
@@ -367,6 +417,30 @@ public partial class App : Application
}
}
private void RefreshFusedDesktopMenuItemVisibility()
{
if (_trayComponentLibraryMenuItem is null)
{
return;
}
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows())
{
_trayComponentLibraryMenuItem.IsVisible = false;
return;
}
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
if (_trayComponentLibraryMenuItem.IsVisible)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
}
private void DisposeTrayIcon()
{
if (_trayIcon is null)
@@ -481,9 +555,9 @@ public partial class App : Application
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
}
private async void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
Dispatcher.UIThread.Post(async () =>
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
@@ -492,19 +566,19 @@ public partial class App : Application
try
{
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
if (mainWindow._isFirstLaunchAfterOpen)
{
mainWindow._isFirstLaunchAfterOpen = false;
mainWindow.ForceDesktopPageToFirst();
}
await mainWindow.SlideInAsync();
}
if (mainWindow.WindowState == WindowState.Minimized)
@@ -520,6 +594,12 @@ public partial class App : Application
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
@@ -536,6 +616,18 @@ public partial class App : Application
}
}, DispatcherPriority.Send);
}
private void EnsureTransparentOverlayWindow()
{
if (_transparentOverlayWindow is null)
{
_transparentOverlayWindow = new TransparentOverlayWindow();
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
{
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay");
};
}
}
internal void PrepareForShutdown(bool isRestart, string source)
{
@@ -600,7 +692,7 @@ public partial class App : Application
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
@@ -623,6 +715,16 @@ public partial class App : Application
ApplyCurrentCultureFromSettings();
RefreshTrayIconContent();
}
// 检查融合桌面设置是否变更
var fusedDesktopChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase);
if (fusedDesktopChanged)
{
RefreshFusedDesktopMenuItemVisibility();
}
}, DispatcherPriority.Background);
}
@@ -886,17 +988,25 @@ public partial class App : Application
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
}
private async void HideMainWindowToTray(MainWindow mainWindow, string source)
internal void HideMainWindowToTray(MainWindow mainWindow, string source)
{
try
{
await mainWindow.SlideOutAsync();
mainWindow.ShowInTaskbar = false;
mainWindow.Hide();
SetDesktopShellState(DesktopShellState.TrayOnly, source);
AppLogger.Info(
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
// 检查三指滑动功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (appSnapshot.EnableThreeFingerSwipe)
{
// 显示透明覆盖层窗口
EnsureTransparentOverlayWindow();
_transparentOverlayWindow?.Show();
}
}
catch (Exception ex)
{

View File

@@ -1,147 +0,0 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Behaviors;
public static class WindowSlideAnimationBehavior
{
private static readonly Easing DecelerateEasing = Easing.Parse(FluttermotionToken.StandardBezier);
private static readonly Easing AccelerateEasing = new CubicEaseIn();
public static readonly TimeSpan SlideInDuration = TimeSpan.FromMilliseconds(350);
public static readonly TimeSpan SlideOutDuration = TimeSpan.FromMilliseconds(280);
public static async Task SlideInAsync(Window window, Border desktopHost)
{
if (window is null || desktopHost is null)
{
return;
}
var screenWidth = Math.Max(1, window.Bounds.Width > 1 ? window.Bounds.Width : PrimaryScreenWidth(window));
var transform = EnsureTranslateTransform(desktopHost);
transform.X = screenWidth;
desktopHost.Opacity = 1;
window.Show();
if (screenWidth <= 1)
{
transform.X = 0;
return;
}
var animation = new Animation
{
Duration = SlideInDuration,
Easing = DecelerateEasing,
Children =
{
new KeyFrame
{
Cue = new Cue(0d),
Setters = { new Setter(TranslateTransform.XProperty, screenWidth) }
},
new KeyFrame
{
Cue = new Cue(1d),
Setters = { new Setter(TranslateTransform.XProperty, 0d) }
}
}
};
await animation.RunAsync(desktopHost);
}
public static async Task SlideOutAsync(Window window, Border desktopHost, Action? onCompleted = null)
{
if (window is null || desktopHost is null)
{
onCompleted?.Invoke();
return;
}
var screenWidth = Math.Max(1, window.Bounds.Width > 1 ? window.Bounds.Width : PrimaryScreenWidth(window));
var transform = EnsureTranslateTransform(desktopHost);
if (screenWidth <= 1)
{
onCompleted?.Invoke();
return;
}
var animation = new Animation
{
Duration = SlideOutDuration,
Easing = AccelerateEasing,
Children =
{
new KeyFrame
{
Cue = new Cue(0d),
Setters = { new Setter(TranslateTransform.XProperty, 0d) }
},
new KeyFrame
{
Cue = new Cue(1d),
Setters = { new Setter(TranslateTransform.XProperty, screenWidth) }
}
}
};
await animation.RunAsync(desktopHost);
onCompleted?.Invoke();
}
public static void ResetSlidePosition(Border desktopHost)
{
if (desktopHost is null)
{
return;
}
var transform = desktopHost.RenderTransform as TranslateTransform;
if (transform is not null)
{
transform.X = 0;
}
desktopHost.Opacity = 1;
}
private static TranslateTransform EnsureTranslateTransform(Border desktopHost)
{
if (desktopHost.RenderTransform is TranslateTransform existingTransform)
{
return existingTransform;
}
var newTransform = new TranslateTransform();
desktopHost.RenderTransform = newTransform;
return newTransform;
}
private static double PrimaryScreenWidth(Window window)
{
try
{
if (window.Screens?.Primary is { } screen)
{
return screen.WorkingArea.Width;
}
}
catch
{
}
return 1920;
}
}

View File

@@ -44,4 +44,8 @@ public static class BuiltInComponentIds
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox";
public const string DesktopShortcut = "DesktopShortcut";
public const string DesktopStickyNote = "DesktopStickyNote";
}

View File

@@ -327,6 +327,16 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStickyNote,
"Sticky Note",
"Notepad",
"Board",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBrowser,
"Browser",
@@ -400,6 +410,36 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopFileManager,
"文件管理",
"Folder",
"File",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopNotificationBox,
"消息盒子",
"Inbox",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopShortcut,
"快捷方式",
"App",
"File",
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};

View File

@@ -2,12 +2,12 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
</PropertyGroup>
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
@@ -36,7 +36,7 @@
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
@@ -76,19 +76,37 @@
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
<PackageReference Include="log4net" Version="3.3.0" />
</ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
<Target Name="CopyLauncherToOutput" AfterTargets="Build">
<PropertyGroup>
<_LauncherOutputPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherOutputPath>
</PropertyGroup>
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherFiles Include="$(_LauncherOutputPath)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherOutputPath)')" />
</Target>
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<Target Name="PublishLauncher" BeforeTargets="CopyLauncherToPublish" Condition="'$(PublishDir)' != '' and '$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<_LauncherPublishPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishPath>
</PropertyGroup>
<MSBuild Projects="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
Targets="Publish"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=$(SelfContained);PublishDir=$(_LauncherPublishPath);PublishSingleFile=false;PublishTrimmed=false;PublishReadyToRun=false;DebugType=none;DebugSymbols=false" />
</Target>
<Target Name="CopyLauncherToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<PropertyGroup>
<_LauncherPublishSource Condition="'$(RuntimeIdentifier)' != '' and Exists('..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\')">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishSource>
<_LauncherPublishSource Condition="'$(_LauncherPublishSource)' == ''">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherPublishSource>
</PropertyGroup>
<ItemGroup>
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherPublishFiles Include="$(_LauncherPublishSource)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherPublishSource)')" />
</Target>
</Project>

View File

@@ -3,7 +3,7 @@
"tray.tooltip": "LanMountainDesktop",
"tray.menu.show_desktop": "Open Desktop",
"tray.menu.settings": "Settings",
"tray.menu.component_library": "Component Library",
"tray.menu.component_library": "Fused Desktop Settings",
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
@@ -388,6 +388,41 @@
"settings.status_bar.clock_format_label": "Clock format",
"settings.status_bar.clock_format.hm": "Hour:Minute",
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
"settings.status_bar.clock_position_label": "Clock position",
"settings.status_bar.clock_position.left": "Left",
"settings.status_bar.clock_position.center": "Center",
"settings.status_bar.clock_position.right": "Right",
"settings.status_bar.text_capsule_header": "Text Capsule",
"settings.status_bar.text_capsule_description": "Display custom text on the status bar with Markdown support.",
"settings.status_bar.text_capsule_position_label": "Text capsule position",
"settings.status_bar.text_capsule_position.left": "Left",
"settings.status_bar.text_capsule_position.center": "Center",
"settings.status_bar.text_capsule_position.right": "Right",
"settings.status_bar.text_capsule_content_label": "Text content (Markdown supported)",
"settings.status_bar.text_capsule_transparent_background_label": "Transparent background",
"settings.status_bar.network_speed_header": "Network Speed",
"settings.status_bar.network_speed_description": "Display real-time network upload and download speed on the status bar.",
"settings.status_bar.network_speed_position_label": "Network speed position",
"settings.status_bar.network_speed_position.left": "Left",
"settings.status_bar.network_speed_position.center": "Center",
"settings.status_bar.network_speed_position.right": "Right",
"settings.status_bar.network_speed_mode_label": "Display mode",
"settings.status_bar.network_speed_mode.both": "Upload + Download",
"settings.status_bar.network_speed_mode.upload": "Upload only",
"settings.status_bar.network_speed_mode.download": "Download only",
"settings.status_bar.network_speed_transparent_background_label": "Transparent background",
"settings.status_bar.show_network_type_icon_label": "Show network type icon",
"settings.status_bar.shadow_header": "Status Bar Shadow",
"settings.status_bar.shadow_desc": "Add shadow effect to the status bar for better visibility of transparent components.",
"settings.status_bar.shadow_enabled_label": "Enable shadow",
"settings.status_bar.shadow_color_label": "Shadow color",
"settings.status_bar.shadow_opacity_label": "Shadow opacity",
"settings.status_bar.theme_header": "Status Bar Theme",
"settings.status_bar.theme_desc": "Set the theme mode for the status bar independently.",
"settings.status_bar.theme_mode_label": "Theme mode",
"settings.status_bar.theme_mode.follow_global": "Follow Global",
"settings.status_bar.theme_mode.dark": "Dark",
"settings.status_bar.theme_mode.light": "Light",
"settings.components.title": "Components",
"settings.components.description": "Adjust component layout and corner design.",
"settings.components.grid_header": "Grid Settings",
@@ -529,6 +564,10 @@
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "App",
"settings.launcher.restore_button": "Unhide",
"settings.launcher.appearance_header": "Appearance",
"settings.launcher.appearance_desc": "Customize the appearance of the App Launcher.",
"settings.launcher.show_tile_background_header": "Show tile background",
"settings.launcher.show_tile_background_desc": "Display a background card behind each app icon. When turned off, only the icon is shown for a cleaner look.",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
@@ -659,6 +698,7 @@
"component.editor.placement_label": "Placement ID",
"component.editor.scope_label": "Scope",
"component.editor.scope_instance": "Instance-scoped editor",
"component_category.all": "All",
"component_category.clock": "Clock",
"component_category.date": "Calendar",
"component_category.weather": "Weather",
@@ -1040,7 +1080,9 @@
"zhijiaohub.settings.source": "Image Source",
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
"zhijiaohub.settings.sectl": "SECTL Gallery",
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
"zhijiaohub.settings.rinlit": "Rin's Gallery",
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto Memes",
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community, Rin's Gallery contains content from Rin's community, Jiangtokoto Memes contains rich meme resources.",
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
@@ -1050,5 +1092,23 @@
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
"zhijiaohub.settings.about": "About",
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally.",
"power.menu": "Power",
"power.title": "Power",
"power.back": "Back",
"power.shutdown": "Shutdown",
"power.restart": "Restart",
"power.logout": "Log Out",
"power.sleep": "Sleep",
"power.lock_screen": "Lock Screen",
"power.shutdown_confirm_title": "Shutdown Confirmation",
"power.shutdown_confirm_message": "Are you sure you want to shut down this computer? Unsaved data may be lost.",
"power.restart_confirm_title": "Restart Confirmation",
"power.restart_confirm_message": "Are you sure you want to restart this computer? Unsaved data may be lost.",
"power.logout_confirm_title": "Log Out Confirmation",
"power.logout_confirm_message": "Are you sure you want to log out?",
"power.sleep_confirm_title": "Sleep Confirmation",
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
"power.confirm_yes": "Yes",
"power.confirm_cancel": "Cancel"
}

View File

@@ -331,6 +331,41 @@
"settings.status_bar.clock_format_label": "時計の形式",
"settings.status_bar.clock_format.hm": "時:分",
"settings.status_bar.clock_format.hms": "時:分:秒",
"settings.status_bar.clock_position_label": "時計の位置",
"settings.status_bar.clock_position.left": "左",
"settings.status_bar.clock_position.center": "中央",
"settings.status_bar.clock_position.right": "右",
"settings.status_bar.text_capsule_header": "テキストカプセル",
"settings.status_bar.text_capsule_description": "ステータスバーにMarkdown形式のカスタムテキストを表示します。",
"settings.status_bar.text_capsule_position_label": "テキストカプセルの位置",
"settings.status_bar.text_capsule_position.left": "左",
"settings.status_bar.text_capsule_position.center": "中央",
"settings.status_bar.text_capsule_position.right": "右",
"settings.status_bar.text_capsule_content_label": "テキスト内容Markdown対応",
"settings.status_bar.text_capsule_transparent_background_label": "透明な背景",
"settings.status_bar.network_speed_header": "ネットワーク速度",
"settings.status_bar.network_speed_description": "ステータスバーにリアルタイムのネットワーク速度を表示します。",
"settings.status_bar.network_speed_position_label": "ネットワーク速度の位置",
"settings.status_bar.network_speed_position.left": "左",
"settings.status_bar.network_speed_position.center": "中央",
"settings.status_bar.network_speed_position.right": "右",
"settings.status_bar.network_speed_mode_label": "表示モード",
"settings.status_bar.network_speed_mode.both": "アップロード + ダウンロード",
"settings.status_bar.network_speed_mode.upload": "アップロードのみ",
"settings.status_bar.network_speed_mode.download": "ダウンロードのみ",
"settings.status_bar.network_speed_transparent_background_label": "透明な背景",
"settings.status_bar.show_network_type_icon_label": "ネットワークタイプアイコンを表示",
"settings.status_bar.shadow_header": "ステータスバーの影",
"settings.status_bar.shadow_desc": "透明なコンポーネントの視認性を高めるために、ステータスバーに影効果を追加します。",
"settings.status_bar.shadow_enabled_label": "影を有効にする",
"settings.status_bar.shadow_color_label": "影の色",
"settings.status_bar.shadow_opacity_label": "影の不透明度",
"settings.status_bar.theme_header": "ステータスバーのテーマ",
"settings.status_bar.theme_desc": "ステータスバーのテーマモードを独立して設定します。",
"settings.status_bar.theme_mode_label": "テーマモード",
"settings.status_bar.theme_mode.follow_global": "グローバルに従う",
"settings.status_bar.theme_mode.dark": "ダーク",
"settings.status_bar.theme_mode.light": "ライト",
"settings.components.title": "コンポーネント",
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
"settings.components.grid_header": "グリッド設定",

View File

@@ -377,6 +377,41 @@
"settings.status_bar.clock_format_label": "시계 형식",
"settings.status_bar.clock_format.hm": "시:분",
"settings.status_bar.clock_format.hms": "시:분:초",
"settings.status_bar.clock_position_label": "시계 위치",
"settings.status_bar.clock_position.left": "왼쪽",
"settings.status_bar.clock_position.center": "가욍데",
"settings.status_bar.clock_position.right": "오른쪽",
"settings.status_bar.text_capsule_header": "텍스트 캡슐",
"settings.status_bar.text_capsule_description": "Markdown 형식의 사용자 정의 텍스트를 상태 표시줄에 표시합니다.",
"settings.status_bar.text_capsule_position_label": "텍스트 캡슐 위치",
"settings.status_bar.text_capsule_position.left": "왼쪽",
"settings.status_bar.text_capsule_position.center": "가욍데",
"settings.status_bar.text_capsule_position.right": "오른쪽",
"settings.status_bar.text_capsule_content_label": "텍스트 내용 (Markdown 지원)",
"settings.status_bar.text_capsule_transparent_background_label": "투명 배경",
"settings.status_bar.network_speed_header": "네트워크 속도",
"settings.status_bar.network_speed_description": "상태 표시줄에 실시간 네트워크 속도를 표시합니다.",
"settings.status_bar.network_speed_position_label": "네트워크 속도 위치",
"settings.status_bar.network_speed_position.left": "왼쪽",
"settings.status_bar.network_speed_position.center": "가욍데",
"settings.status_bar.network_speed_position.right": "오른쪽",
"settings.status_bar.network_speed_mode_label": "표시 모드",
"settings.status_bar.network_speed_mode.both": "업로드 + 다운로드",
"settings.status_bar.network_speed_mode.upload": "업로드만",
"settings.status_bar.network_speed_mode.download": "다운로드만",
"settings.status_bar.network_speed_transparent_background_label": "투명 배경",
"settings.status_bar.show_network_type_icon_label": "네트워크 유형 아이콘 표시",
"settings.status_bar.shadow_header": "상태 표시줄 그림자",
"settings.status_bar.shadow_desc": "투명한 구성 요소의 가시성을 높이기 위해 상태 표시줄에 그림자 효과를 추가합니다.",
"settings.status_bar.shadow_enabled_label": "그림자 활성화",
"settings.status_bar.shadow_color_label": "그림자 색상",
"settings.status_bar.shadow_opacity_label": "그림자 불투명도",
"settings.status_bar.theme_header": "상태 표시줄 테마",
"settings.status_bar.theme_desc": "상태 표시줄의 테마 모드를 독립적으로 설정합니다.",
"settings.status_bar.theme_mode_label": "테마 모드",
"settings.status_bar.theme_mode.follow_global": "전역 따르기",
"settings.status_bar.theme_mode.dark": "다크",
"settings.status_bar.theme_mode.light": "라이트",
"settings.components.title": "컴포넌트",
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
"settings.components.grid_header": "그리드 설정",

View File

@@ -3,7 +3,7 @@
"tray.tooltip": "LanMountainDesktop",
"tray.menu.show_desktop": "打开桌面",
"tray.menu.settings": "设置",
"tray.menu.component_library": "独立组件库",
"tray.menu.component_library": "融合桌面设置",
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
@@ -383,6 +383,41 @@
"settings.status_bar.clock_format_label": "时钟格式",
"settings.status_bar.clock_format.hm": "时:分",
"settings.status_bar.clock_format.hms": "时:分:秒",
"settings.status_bar.clock_position_label": "时钟位置",
"settings.status_bar.clock_position.left": "靠左",
"settings.status_bar.clock_position.center": "居中",
"settings.status_bar.clock_position.right": "靠右",
"settings.status_bar.text_capsule_header": "文字胶囊",
"settings.status_bar.text_capsule_description": "在状态栏显示自定义文字,支持 Markdown 格式。",
"settings.status_bar.text_capsule_position_label": "文字胶囊位置",
"settings.status_bar.text_capsule_position.left": "靠左",
"settings.status_bar.text_capsule_position.center": "居中",
"settings.status_bar.text_capsule_position.right": "靠右",
"settings.status_bar.text_capsule_content_label": "文字内容(支持 Markdown",
"settings.status_bar.text_capsule_transparent_background_label": "透明背景",
"settings.status_bar.network_speed_header": "网速显示",
"settings.status_bar.network_speed_description": "在状态栏显示实时网络上传和下载速度。",
"settings.status_bar.network_speed_position_label": "网速显示位置",
"settings.status_bar.network_speed_position.left": "靠左",
"settings.status_bar.network_speed_position.center": "居中",
"settings.status_bar.network_speed_position.right": "靠右",
"settings.status_bar.network_speed_mode_label": "显示模式",
"settings.status_bar.network_speed_mode.both": "上传 + 下载",
"settings.status_bar.network_speed_mode.upload": "仅上传",
"settings.status_bar.network_speed_mode.download": "仅下载",
"settings.status_bar.network_speed_transparent_background_label": "透明背景",
"settings.status_bar.show_network_type_icon_label": "显示网络类型图标",
"settings.status_bar.shadow_header": "状态栏阴影",
"settings.status_bar.shadow_desc": "为状态栏添加阴影效果,使透明背景的组件更清晰。",
"settings.status_bar.shadow_enabled_label": "启用阴影",
"settings.status_bar.shadow_color_label": "阴影颜色",
"settings.status_bar.shadow_opacity_label": "阴影透明度",
"settings.status_bar.theme_header": "状态栏主题",
"settings.status_bar.theme_desc": "独立设置状态栏的主题模式。",
"settings.status_bar.theme_mode_label": "主题模式",
"settings.status_bar.theme_mode.follow_global": "跟随全局",
"settings.status_bar.theme_mode.dark": "暗色",
"settings.status_bar.theme_mode.light": "浅色",
"settings.components.title": "组件",
"settings.components.description": "调整组件布局与圆角设计。",
"settings.components.grid_header": "网格设置",
@@ -523,6 +558,10 @@
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "应用",
"settings.launcher.restore_button": "取消隐藏",
"settings.launcher.appearance_header": "外观",
"settings.launcher.appearance_desc": "自定义应用启动台的外观样式。",
"settings.launcher.show_tile_background_header": "显示图标卡片背景",
"settings.launcher.show_tile_background_desc": "在应用图标后显示卡片背景,关闭后仅显示图标更加简洁。",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
@@ -653,6 +692,7 @@
"component.editor.placement_label": "实例 ID",
"component.editor.scope_label": "作用域",
"component.editor.scope_instance": "实例级编辑器",
"component_category.all": "全部",
"component_category.clock": "时钟",
"component_category.date": "日历",
"component_category.weather": "天气",
@@ -1034,7 +1074,9 @@
"zhijiaohub.settings.source": "图片源",
"zhijiaohub.settings.classisland": "ClassIsland 图库",
"zhijiaohub.settings.sectl": "SECTL 图库",
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容。",
"zhijiaohub.settings.rinlit": "Rin's 图库",
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto 表情包",
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容Rin's 图库包含 Rin's 社区的内容Jiangtokoto 表情包包含丰富的表情包资源。",
"zhijiaohub.settings.mirror_source": "镜像加速",
"zhijiaohub.settings.mirror_direct": "直连GitHub",
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
@@ -1044,5 +1086,23 @@
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
"zhijiaohub.settings.about": "关于",
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。",
"power.menu": "电源",
"power.title": "电源",
"power.back": "返回",
"power.shutdown": "关机",
"power.restart": "重启",
"power.logout": "注销",
"power.sleep": "睡眠",
"power.lock_screen": "锁定屏幕",
"power.shutdown_confirm_title": "关机确认",
"power.shutdown_confirm_message": "确定要关闭计算机吗?未保存的数据可能会丢失。",
"power.restart_confirm_title": "重启确认",
"power.restart_confirm_message": "确定要重启计算机吗?未保存的数据可能会丢失。",
"power.logout_confirm_title": "注销确认",
"power.logout_confirm_message": "确定要注销当前用户吗?",
"power.sleep_confirm_title": "睡眠确认",
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
"power.confirm_yes": "确定",
"power.confirm_cancel": "取消"
}

View File

@@ -19,6 +19,8 @@ public sealed class AppSettingsSnapshot
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
public string CornerRadiusStyle { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none";
@@ -112,12 +114,54 @@ public sealed class AppSettingsSnapshot
public bool StatusBarClockTransparentBackground { get; set; }
public string ClockPosition { get; set; } = "Left"; // Left, Center, Right
public string ClockFontSize { get; set; } = "Medium"; // Small, Medium, Large
public bool ShowTextCapsule { get; set; } = false;
public string TextCapsuleContent { get; set; } = "**Hello** World!";
public string TextCapsulePosition { get; set; } = "Right"; // Left, Center, Right
public bool TextCapsuleTransparentBackground { get; set; } = false;
public string TextCapsuleFontSize { get; set; } = "Medium"; // Small, Medium, Large
public bool ShowNetworkSpeed { get; set; } = false;
public string NetworkSpeedPosition { get; set; } = "Right"; // Left, Center, Right
public string NetworkSpeedDisplayMode { get; set; } = "Both"; // Upload, Download, Both
public bool NetworkSpeedTransparentBackground { get; set; } = false;
public bool ShowNetworkTypeIcon { get; set; } = false;
public string NetworkSpeedFontSize { get; set; } = "Medium"; // Small, Medium, Large
public string StatusBarSpacingMode { get; set; } = "Relaxed";
public bool StatusBarShadowEnabled { get; set; } = false;
public string StatusBarShadowColor { get; set; } = "#000000";
public double StatusBarShadowOpacity { get; set; } = 0.3;
public int StatusBarCustomSpacingPercent { get; set; } = 12;
public bool EnableThreeFingerSwipe { get; set; } = false;
public bool EnableSlideTransition { get; set; } = false;
public bool EnableFusedDesktop { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }
public string? DevPluginPath { get; set; }
#region Study Settings
public bool StudyEnabled { get; set; } = true;
@@ -166,6 +210,35 @@ public sealed class AppSettingsSnapshot
#endregion
#region Notification Box Settings ()
/// <summary>
/// 启用消息盒子功能Windows通知监听
/// </summary>
public bool NotificationBoxEnabled { get; set; } = true;
/// <summary>
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
/// </summary>
public bool NotificationBoxPrivacyMode { get; set; } = false;
/// <summary>
/// 被屏蔽的应用列表(不接收这些应用的通知)
/// </summary>
public List<string> NotificationBoxBlockedApps { get; set; } = [];
/// <summary>
/// 历史记录保留天数
/// </summary>
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
/// <summary>
/// 最大存储通知数量(防止内存无限增长)
/// </summary>
public int NotificationBoxMaxStoredCount { get; set; } = 500;
#endregion
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();
@@ -179,6 +252,9 @@ public sealed class AppSettingsSnapshot
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds)
: [];
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
? new List<string>(NotificationBoxBlockedApps)
: [];
return clone;
}

View File

@@ -84,6 +84,70 @@ public sealed class ComponentSettingsSnapshot
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
#region Notification Box Component Settings ()
/// <summary>
/// 组件内最大显示通知数量
/// </summary>
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
/// <summary>
/// 排序方式TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
/// </summary>
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
/// <summary>
/// 是否显示应用图标
/// </summary>
public bool NotificationBoxShowAppIcon { get; set; } = true;
/// <summary>
/// 是否显示时间戳
/// </summary>
public bool NotificationBoxShowTimestamp { get; set; } = true;
/// <summary>
/// 时间格式Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
/// </summary>
public string NotificationBoxTimeFormat { get; set; } = "Relative";
/// <summary>
/// 是否按应用分组显示
/// </summary>
public bool NotificationBoxGroupByApp { get; set; } = false;
/// <summary>
/// 是否显示清除按钮
/// </summary>
public bool NotificationBoxShowClearButton { get; set; } = true;
#endregion
#region Shortcut Component Settings ()
/// <summary>
/// 快捷方式目标路径
/// </summary>
public string? ShortcutTargetPath { get; set; }
/// <summary>
/// 点击模式Single(单击打开) 或 Double(双击打开)
/// </summary>
public string ShortcutClickMode { get; set; } = "Double";
/// <summary>
/// 是否显示背景
/// </summary>
public bool ShortcutShowBackground { get; set; } = true;
#endregion
#region Sticky Note Component Settings (便)
public string StickyNoteContent { get; set; } = string.Empty;
#endregion
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -124,15 +188,83 @@ public static class ZhiJiaoHubSources
{
public const string ClassIsland = "classisland";
public const string Sectl = "sectl";
public const string RinLit = "rinlit";
public const string Jiangtokoto = "jiangtokoto";
public static string Normalize(string? value)
{
return value?.ToLowerInvariant() switch
{
"sectl" => Sectl,
"rinlit" => RinLit,
"jiangtokoto" => Jiangtokoto,
_ => ClassIsland
};
}
public static string GetDisplayName(string source)
{
return source?.ToLowerInvariant() switch
{
Sectl => "SECTL 图库",
RinLit => "Rin's 图库",
Jiangtokoto => "Jiangtokoto 表情包",
_ => "ClassIsland 图库"
};
}
}
// 智教Hub数据源配置
public sealed class ZhiJiaoHubSourceConfig
{
public string Owner { get; init; } = string.Empty;
public string Repo { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public bool UseJsonIndex { get; init; } = false;
public string? JsonIndexPath { get; init; } = null;
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
public string? JsonIndexUrl => JsonIndexPath != null
? $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{JsonIndexPath}"
: null;
public static ZhiJiaoHubSourceConfig GetConfig(string source)
{
return source?.ToLowerInvariant() switch
{
ZhiJiaoHubSources.Sectl => new ZhiJiaoHubSourceConfig
{
Owner = "SECTL",
Repo = "SECTL-hub",
Path = "docs/.vuepress/public/images",
DisplayName = "SECTL 图库"
},
ZhiJiaoHubSources.RinLit => new ZhiJiaoHubSourceConfig
{
Owner = "RinLit-233-shiroko",
Repo = "Rin-sHub",
Path = "updates/images",
DisplayName = "Rin's 图库",
UseJsonIndex = true,
JsonIndexPath = "updates/images.json"
},
ZhiJiaoHubSources.Jiangtokoto => new ZhiJiaoHubSourceConfig
{
Owner = "unDefFtr",
Repo = "jiangtokoto-images",
Path = "images",
DisplayName = "Jiangtokoto 表情包"
},
_ => new ZhiJiaoHubSourceConfig
{
Owner = "ClassIsland",
Repo = "classisland-hub",
Path = "images",
DisplayName = "ClassIsland 图库"
}
};
}
}
// 智教Hub镜像加速源常量

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
namespace LanMountainDesktop.Models;
public enum FileSystemItemType
{
Drive,
Directory,
File
}
public sealed class FileSystemItem
{
public string Name { get; init; } = string.Empty;
public string FullPath { get; init; } = string.Empty;
public FileSystemItemType ItemType { get; init; }
public long? Size { get; init; }
public DateTime? LastModified { get; init; }
public string? Extension { get; init; }
public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive;
public static FileSystemItem FromDriveInfo(DriveInfo drive)
{
string name;
long? size = null;
try
{
var volumeLabel = drive.VolumeLabel;
name = string.IsNullOrWhiteSpace(volumeLabel)
? $"{drive.Name.TrimEnd('\\', '/')}"
: $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})";
}
catch
{
name = $"{drive.Name.TrimEnd('\\', '/')}";
}
try
{
var totalSize = drive.TotalSize;
size = totalSize > 0 ? totalSize : null;
}
catch
{
size = null;
}
return new FileSystemItem
{
Name = name,
FullPath = drive.Name,
ItemType = FileSystemItemType.Drive,
Size = size,
LastModified = null,
Extension = null
};
}
public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory)
{
return new FileSystemItem
{
Name = directory.Name,
FullPath = directory.FullName,
ItemType = FileSystemItemType.Directory,
Size = null,
LastModified = directory.LastWriteTime,
Extension = null
};
}
public static FileSystemItem FromFileInfo(FileInfo file)
{
return new FileSystemItem
{
Name = file.Name,
FullPath = file.FullName,
ItemType = FileSystemItemType.File,
Size = file.Length,
LastModified = file.LastWriteTime,
Extension = file.Extension
};
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
/// <summary>
/// 融合桌面组件放置快照 - 用于在系统桌面(负一屏)上放置组件
/// </summary>
public sealed class FusedDesktopComponentPlacementSnapshot
{
/// <summary>
/// 放置实例ID唯一标识
/// </summary>
public string PlacementId { get; set; } = string.Empty;
/// <summary>
/// 组件类型ID
/// </summary>
public string ComponentId { get; set; } = string.Empty;
/// <summary>
/// X 坐标(像素,相对于屏幕左上角)
/// </summary>
public double X { get; set; }
/// <summary>
/// Y 坐标(像素,相对于屏幕左上角)
/// </summary>
public double Y { get; set; }
/// <summary>
/// 宽度(像素)
/// </summary>
public double Width { get; set; } = 200;
/// <summary>
/// 高度(像素)
/// </summary>
public double Height { get; set; } = 200;
/// <summary>
/// Z-Index用于控制组件层叠顺序
/// </summary>
public int ZIndex { get; set; }
/// <summary>
/// 是否锁定位置(锁定后不可拖动)
/// </summary>
public bool IsLocked { get; set; }
/// <summary>
/// 创建深拷贝
/// </summary>
public FusedDesktopComponentPlacementSnapshot Clone()
{
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = PlacementId,
ComponentId = ComponentId,
X = X,
Y = Y,
Width = Width,
Height = Height,
ZIndex = ZIndex,
IsLocked = IsLocked
};
}
}
/// <summary>
/// 融合桌面布局快照 - 包含所有在系统桌面上显示的组件
/// </summary>
public sealed class FusedDesktopLayoutSnapshot
{
/// <summary>
/// 是否启用融合桌面功能
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 组件放置列表
/// </summary>
public List<FusedDesktopComponentPlacementSnapshot> ComponentPlacements { get; set; } = [];
/// <summary>
/// 创建深拷贝
/// </summary>
public FusedDesktopLayoutSnapshot Clone()
{
return new FusedDesktopLayoutSnapshot
{
IsEnabled = IsEnabled,
ComponentPlacements = [.. ComponentPlacements.ConvertAll(p => p.Clone())]
};
}
}

View File

@@ -8,6 +8,8 @@ public sealed class LauncherSettingsSnapshot
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public bool ShowTileBackground { get; set; } = true;
public LauncherSettingsSnapshot Clone()
{
var clone = (LauncherSettingsSnapshot)MemberwiseClone();

View File

@@ -0,0 +1,54 @@
using System;
namespace LanMountainDesktop.Models;
/// <summary>
/// 通知项数据模型
/// </summary>
public sealed class NotificationItem
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// 应用ID如 WeChat, Outlook 等)
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 应用名称
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// 应用图标路径或Base64
/// </summary>
public string? AppIconPath { get; set; }
/// <summary>
/// 通知标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 通知内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedTime { get; set; } = DateTime.Now;
/// <summary>
/// 是否已读
/// </summary>
public bool IsRead { get; set; } = false;
/// <summary>
/// 原始通知的额外数据(用于点击跳转)
/// </summary>
public string? LaunchArgs { get; set; }
}

View File

@@ -37,6 +37,7 @@ public enum StudyDataMode
public sealed record StudyAnalyticsConfig(
int FrameMs = 50,
int UiPublishIntervalMs = 125,
int SliceSec = 30,
double ScoreThresholdDbfs = -50,
int SegmentMergeGapMs = 500,

View File

@@ -6,6 +6,7 @@ using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
@@ -19,6 +20,7 @@ public sealed class Program
public static void Main(string[] args)
{
AppLogger.Initialize();
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);

View File

@@ -0,0 +1,21 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"LanMountainDesktop (Direct)": {
"commandName": "Project",
"commandLineArgs": "",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"LanMountainDesktop (via Launcher)": {
"commandName": "Executable",
"executablePath": "$(SolutionDir)LanMountainDesktop.Launcher\\bin\\$(Configuration)\\net10.0\\LanMountainDesktop.Launcher.exe",
"commandLineArgs": "launch",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -44,7 +44,7 @@ public sealed record AppearanceThemeSnapshot(
string ThemeColorMode,
string? UserThemeColor,
string? SelectedWallpaperSeed,
double GlobalCornerRadiusScale,
string CornerRadiusStyle,
AppearanceCornerRadiusTokens CornerRadiusTokens,
string ResolvedSeedSource,
MonetPalette MonetPalette,
@@ -551,7 +551,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
if (!refreshAll &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
!(respondsToThemeColor &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
!(respondsToWallpaper &&
@@ -573,8 +573,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
bool queueWallpaperPaletteBuild)
{
var availableModes = _windowMaterialService.GetAvailableModes();
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
MonetPalette palette;
IReadOnlyList<Color> wallpaperSeedCandidates;
Color effectiveSeedColor;
@@ -614,7 +614,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
themeColorMode,
themeState.ThemeColor,
selectedWallpaperSeed,
globalCornerRadiusScale,
cornerRadiusStyle,
cornerRadiusTokens,
resolvedSeedSource,
palette,

View File

@@ -267,7 +267,17 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d)
preferredHeight: 520d),
[BuiltInComponentIds.DesktopNotificationBox] = new(
BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d),
[BuiltInComponentIds.DesktopShortcut] = new(
BuiltInComponentIds.DesktopShortcut,
context => new ShortcutComponentEditor(context),
preferredWidth: 420d,
preferredHeight: 400d)
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

@@ -129,7 +129,6 @@ public static class DesktopComponentRegistryFactory
settingsService);
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: appearanceSnapshot.GlobalCornerRadiusScale,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
var pluginContext = new PluginDesktopComponentContext(
@@ -157,7 +156,6 @@ public static class DesktopComponentRegistryFactory
private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext)
{
return new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: chromeContext.GlobalCornerRadiusScale,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens),
ThemeVariant: "Unknown"));
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
/// <summary>
/// 融合桌面布局存储服务接口
/// </summary>
public interface IFusedDesktopLayoutService
{
/// <summary>
/// 加载融合桌面布局
/// </summary>
FusedDesktopLayoutSnapshot Load();
/// <summary>
/// 保存融合桌面布局
/// </summary>
void Save(FusedDesktopLayoutSnapshot snapshot);
/// <summary>
/// 添加组件放置
/// </summary>
void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
/// <summary>
/// 更新组件放置
/// </summary>
void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement);
/// <summary>
/// 移除组件放置
/// </summary>
void RemoveComponentPlacement(string placementId);
/// <summary>
/// 清除所有组件放置
/// </summary>
void ClearAllPlacements();
}
/// <summary>
/// 融合桌面布局存储服务实现
/// </summary>
internal sealed class FusedDesktopLayoutService : IFusedDesktopLayoutService
{
private static readonly string ConfigFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LanMountainDesktop",
"fused_desktop_layout.json");
private readonly object _lock = new();
private FusedDesktopLayoutSnapshot? _cachedSnapshot;
public FusedDesktopLayoutSnapshot Load()
{
lock (_lock)
{
if (_cachedSnapshot is not null)
{
return _cachedSnapshot.Clone();
}
try
{
if (!File.Exists(ConfigFilePath))
{
_cachedSnapshot = new FusedDesktopLayoutSnapshot();
return _cachedSnapshot.Clone();
}
var json = File.ReadAllText(ConfigFilePath);
var snapshot = JsonSerializer.Deserialize<FusedDesktopLayoutSnapshot>(json, JsonOptions);
_cachedSnapshot = snapshot ?? new FusedDesktopLayoutSnapshot();
return _cachedSnapshot.Clone();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktopLayout", "Failed to load fused desktop layout.", ex);
_cachedSnapshot = new FusedDesktopLayoutSnapshot();
return _cachedSnapshot.Clone();
}
}
}
public void Save(FusedDesktopLayoutSnapshot snapshot)
{
lock (_lock)
{
try
{
_cachedSnapshot = snapshot.Clone();
var directory = Path.GetDirectoryName(ConfigFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, JsonOptions);
File.WriteAllText(ConfigFilePath, json);
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktopLayout", "Failed to save fused desktop layout.", ex);
}
}
}
public void AddComponentPlacement(FusedDesktopComponentPlacementSnapshot placement)
{
var snapshot = Load();
snapshot.ComponentPlacements.Add(placement);
Save(snapshot);
}
public void UpdateComponentPlacement(FusedDesktopComponentPlacementSnapshot placement)
{
var snapshot = Load();
var index = snapshot.ComponentPlacements.FindIndex(p => p.PlacementId == placement.PlacementId);
if (index >= 0)
{
snapshot.ComponentPlacements[index] = placement;
Save(snapshot);
}
}
public void RemoveComponentPlacement(string placementId)
{
var snapshot = Load();
snapshot.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId);
Save(snapshot);
}
public void ClearAllPlacements()
{
var snapshot = Load();
snapshot.ComponentPlacements.Clear();
Save(snapshot);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
/// <summary>
/// 融合桌面布局服务提供者
/// </summary>
public static class FusedDesktopLayoutServiceProvider
{
private static IFusedDesktopLayoutService? _instance;
private static readonly object _lock = new();
public static IFusedDesktopLayoutService GetOrCreate()
{
if (_instance is not null)
{
return _instance;
}
lock (_lock)
{
_instance ??= new FusedDesktopLayoutService();
return _instance;
}
}
}

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