Compare commits

..

27 Commits

Author SHA1 Message Date
lincube
cab35f4c22 0.5.1
插件系统试验
2026-03-09 12:27:33 +08:00
lincube
c9f92a4755 0.5.0
设置优化
2026-03-08 14:00:13 +08:00
lincube
854deae801 0.4.12
模块化解耦
2026-03-08 04:22:19 +08:00
lincube
d72cd42483 0.4.11 2026-03-08 03:23:03 +08:00
lincube
3aee31c6c0 0.4.10
自习数据采样优化
2026-03-07 22:44:00 +08:00
lincube
435b96c50c 0.4.9.3
内存泄露问题解决
2026-03-07 22:05:18 +08:00
lincube
49b18d6af1 0.4.9.2 2026-03-07 19:59:28 +08:00
lincube
d6ec159af4 0.4.9.1
小修复
2026-03-07 17:25:29 +08:00
lincube
0d14675cc0 0.4.9
Linux相关版本适配
2026-03-07 00:58:52 +08:00
lincube
1f509959a9 0.4.8
百度热搜组件、凤凰新闻组件。
2026-03-06 22:24:59 +08:00
lincube
382d1baaf1 0.4.7
2×2英语单词组件,修复了stcn组件
2026-03-06 18:38:20 +08:00
lincube
72a0be16b3 0.4.6
引入了托盘菜单,提供了应用启动台隐藏应用功能,优化了自动刷新功能,为STCN 24组件提供了更多信息选项。
2026-03-06 10:32:02 +08:00
lincube
de40471af6 0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
2026-03-06 08:53:45 +08:00
lincube
5d35e0d21c 0.4.4
bilibili热搜组件
2026-03-06 00:29:40 +08:00
lincube
e917a1e4af 0.4.3fixed 2026-03-05 21:21:03 +08:00
lincube
b8643a2959 0.4.3
新增英语句子组件。优化央广网新闻组件,优化每日单词组件
2026-03-05 20:17:28 +08:00
lincube
3b71486423 0.4.2
修复视频壁纸恶性bug,添加央广网组件。
2026-03-05 18:46:32 +08:00
lincube
8768fa1ed2 0.4.1
修天气,时钟,每日图片....
2026-03-05 17:09:46 +08:00
lincube
24f1b896e1 0.4.0 2026-03-05 16:34:22 +08:00
lincube
3cdb4bbd98 cifixed5 2026-03-05 14:45:23 +08:00
lincube
f3e7f88a39 0.3.14 2026-03-05 14:34:33 +08:00
lincube
d182925b58 0.3.13
动画优化
2026-03-05 14:03:35 +08:00
lincube
2e49602bff cifixed4 2026-03-05 13:42:25 +08:00
lincube
c720d16e81 cifixed3 2026-03-05 13:02:15 +08:00
lincube
469f7e1132 0.3.12
修复
2026-03-05 12:34:39 +08:00
lincube
00694e715f ci: Fix artifact path pattern for release action 2026-03-05 01:08:37 +08:00
lincube
6803d0eb72 ci: Add debug logging to identify artifact packaging issue 2026-03-05 01:01:42 +08:00
209 changed files with 33922 additions and 2010 deletions

View File

@@ -36,26 +36,24 @@ QODANA_ENDPOINT=https://qodana.cloud
```
### 3. Release & Publish (`release.yml`)
**Trigger:** Push git tags (v1.0.0, release-1.0.0), or manual workflow dispatch
**Trigger:** Push git tags (`v*`, e.g. `v1.0.0`), or manual workflow dispatch
**What it does:**
- Builds for **Windows** (x64, x86) - self-contained executables
- Builds for **Linux** (x64) - tar.gz packages
- Builds for **macOS** (x64, arm64) - universal support
- Builds **Windows** installers (x64, x86) via Inno Setup
- Builds **Linux** packages (x64) as `.deb`
- Builds **macOS** packages (x64, arm64) as `.dmg`
- Publishes optimized release builds for all platforms
- Generates GitHub Release with all platform artifacts
- Generates GitHub Release with installer/package assets
- Supports pre-release versions
**Supported Platforms:**
| Platform | Architectures | Output Format | Status |
|----------|---------------|---------------|--------|
| Windows | x64, x86 | .zip | ✅ Full support |
| Linux | x64 | .tar.gz | ✅ Full support |
| macOS | x64, arm64 (Apple Silicon) | .tar.gz | ✅ Full support |
| Windows | x64, x86 | .exe (installer) | ✅ Full support |
| Linux | x64 | .deb | ✅ Full support |
| macOS | x64, arm64 (Apple Silicon) | .dmg | ✅ Full support |
**Build Scripts:**
- Windows: Uses PowerShell (`LanMountainDesktop\scripts\package.ps1`)
- Linux/macOS: Uses Bash (`scripts/build.sh`)
> Note: GitHub Actions artifacts are downloaded as zip containers. The actual packaged files inside are `.exe`, `.deb`, and `.dmg`.
**Usage:**
@@ -66,13 +64,9 @@ git push origin v1.0.0
# Automatically triggers Windows + Linux + macOS builds
```
*Manual trigger with selective platforms:*
*Manual trigger:*
Go to GitHub > Actions > Release & Publish > Run workflow
- Specify version: `1.0.0`
- Toggle build targets as needed:
- ✅ Build Windows (x64/x86)
- ✅ Build Linux (x64)
- ✅ Build macOS (x64/arm64)
- Specify release tag: `v1.0.0` (or `1.0.0`, workflow will normalize to `v1.0.0`)
- Check pre-release option if needed
### 4. Issue Management (`issue-management.yml`)

View File

@@ -2,6 +2,8 @@
on:
push:
tags-ignore:
- '*'
pull_request:
workflow_dispatch:

View File

@@ -26,24 +26,36 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
steps:
- name: Get release info
id: version
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG=${GITHUB_REF#refs/tags/}
TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}"
else
TAG=${{ github.event.inputs.tag }}
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
TAG="${RAW_TAG#refs/tags/}"
elif [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHECKOUT_REF="${GITHUB_SHA}"
fi
VERSION=${TAG#v}
VERSION="${TAG#v}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
build-windows:
needs: prepare
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
arch: [x64, x86]
name: Build_Windows_${{ matrix.arch }}
@@ -54,7 +66,7 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -89,13 +101,12 @@ jobs:
-o ./publish/windows-${{ matrix.arch }} `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=true `
-p:PublishSingleFile=false `
-p:SelfContained=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-p:PublishReadyToRun=true
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false
shell: pwsh
- name: Install Inno Setup
@@ -126,14 +137,36 @@ jobs:
exit 1
}
# Find Inno Setup compiler
$isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
if (-not (Test-Path -Path $isccPath)) {
$isccPath = "C:\Program Files\Inno Setup 6\ISCC.exe"
# Find Inno Setup compiler (choco may install a shim in PATH)
$isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
if (-not (Test-Path -Path $isccPath)) {
Write-Error "Inno Setup compiler not found at: $isccPath"
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
if (-not $isccPath) {
foreach ($candidate in $candidatePaths) {
if ($candidate -and (Test-Path -Path $candidate)) {
$isccPath = $candidate
break
}
}
}
if (-not $isccPath) {
Write-Host "ISCC.exe was not found in PATH or known locations."
Write-Host "Checked locations:"
$candidatePaths | ForEach-Object { Write-Host " - $_" }
Write-Host "Chocolatey bin listing (if exists):"
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
Write-Error "Inno Setup compiler not found."
exit 1
}
@@ -142,20 +175,26 @@ jobs:
# Build installer with iscc.exe
Write-Host "Building installer for Windows $arch with version $version..."
$compileCmd = @(
"`"$isccPath`"",
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
$installerScript = (Resolve-Path $installerScript).Path
$compileArgs = @(
"/DMyAppVersion=$version",
"/DPublishDir=..\$publishDir",
"/DMyOutputDir=..\$outputDir",
"/DPublishDir=$publishDir",
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
"`"$installerScript`""
) -join " "
$installerScript
)
Write-Host "Compile command: $compileCmd"
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
# Execute the compiler
$output = Invoke-Expression $compileCmd 2>&1
Write-Host $output
& $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
@@ -173,6 +212,7 @@ jobs:
with:
name: release-windows-${{ matrix.arch }}
path: build-installer/*.exe
if-no-files-found: error
retention-days: 30
build-linux:
@@ -186,7 +226,7 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: |
@@ -221,13 +261,12 @@ jobs:
-o ./publish/linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=true \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-p:PublishReadyToRun=true
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
- name: Package as DEB
run: |
@@ -236,6 +275,8 @@ jobs:
package_name="LanMountainDesktop"
package_version="${version}"
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
@@ -249,6 +290,7 @@ jobs:
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/"
@@ -261,19 +303,48 @@ jobs:
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
{
printf '%s\n' '#!/bin/sh'
printf '%s\n' 'set -e'
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
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)
cat > "build-deb/DEBIAN/control" << EOF
Package: $package_name
Version: $package_version
Architecture: $arch
Maintainer: LanMountain Team <dev@example.com>
Description: LanMountain Desktop Application
A desktop application for LanMountain.
EOF
{
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch"
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
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 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
@@ -289,6 +360,7 @@ EOF
with:
name: release-linux
path: "*.deb"
if-no-files-found: error
retention-days: 30
build-macos:
@@ -305,7 +377,7 @@ EOF
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -331,13 +403,12 @@ EOF
-o ./publish/macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=true \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-p:PublishReadyToRun=true
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
- name: Package as DMG
run: |
@@ -370,27 +441,27 @@ EOF
exit 1
fi
# Create Info.plist - NOTE: Using unquoted EOF to allow variable expansion
cat > "${app_name}.app/Contents/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>LanMountainDesktop</string>
<key>CFBundleName</key>
<string>LanMountain Desktop</string>
<key>CFBundleVersion</key>
<string>$version</string>
<key>CFBundleShortVersionString</key>
<string>$version</string>
<key>CFBundleIdentifier</key>
<string>com.lanmountain.desktop</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
EOF
# 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' ' <key>CFBundleName</key>'
printf '%s\n' ' <string>LanMountain Desktop</string>'
printf '%s\n' ' <key>CFBundleVersion</key>'
printf '%s\n' " <string>$version</string>"
printf '%s\n' ' <key>CFBundleShortVersionString</key>'
printf '%s\n' " <string>$version</string>"
printf '%s\n' ' <key>CFBundleIdentifier</key>'
printf '%s\n' ' <string>com.lanmountain.desktop</string>'
printf '%s\n' ' <key>CFBundlePackageType</key>'
printf '%s\n' ' <string>APPL</string>'
printf '%s\n' '</dict>'
printf '%s\n' '</plist>'
} > "${app_name}.app/Contents/Info.plist"
# Create DMG
mkdir -p dmg-temp
@@ -412,12 +483,12 @@ EOF
with:
name: release-macos-${{ matrix.arch }}
path: "*.dmg"
if-no-files-found: error
retention-days: 30
github-release:
needs: [ prepare, build-windows, build-linux, build-macos ]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
@@ -428,13 +499,44 @@ EOF
path: artifacts
pattern: release-*
- name: List artifacts structure
run: |
echo "🔍 Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "📊 Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "📁 Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
- name: Flatten artifacts for release
run: |
echo "📦 Organizing artifacts..."
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
echo ""
echo "✅ Files ready for release:"
ls -lh release-files/ || echo "⚠️ No files found in release-files"
echo ""
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"
exit 1
fi
- name: Create Release
uses: ncipollo/release-action@v1
with:
tag: ${{ github.ref_name }}
tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true
draft: false
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: artifacts/**/*
artifacts: "release-files/**"
body: |
## Release ${{ needs.prepare.outputs.version }}

7
.gitignore vendored
View File

@@ -481,3 +481,10 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
nul
/publish-test
/_build_verify
/_build_verify_tray
/_build_verify_plugin
/_build_verify_plugin_tabs
/_build_verify_sample_plugin
/_build_verify_sample_plugin_capabilities

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPlugin
{
void Initialize(IPluginContext context);
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginContext
{
PluginManifest Manifest { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IServiceProvider Services { get; }
IReadOnlyDictionary<string, object?> Properties { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,74 @@
using System.Reflection;
using System.Threading;
namespace LanMountainDesktop.PluginSdk;
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
{
private int _disposed;
internal LoadedPlugin(
PluginManifest manifest,
string sourcePath,
string assemblyPath,
Assembly assembly,
IPlugin plugin,
IPluginContext context,
IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
PluginLoadContext loadContext)
{
Manifest = manifest;
SourcePath = sourcePath;
AssemblyPath = assemblyPath;
Assembly = assembly;
Plugin = plugin;
Context = context;
SettingsPages = settingsPages;
DesktopComponents = desktopComponents;
LoadContext = loadContext;
}
public PluginManifest Manifest { get; }
public string SourcePath { get; }
public string AssemblyPath { get; }
public Assembly Assembly { get; }
public IPlugin Plugin { get; }
public IPluginContext Context { get; }
public IReadOnlyList<PluginSettingsPageRegistration> SettingsPages { get; }
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
public PluginLoadContext LoadContext { get; }
public void Dispose()
{
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (Plugin is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (Plugin is IDisposable disposable)
{
disposable.Dispose();
}
LoadContext.Unload();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public abstract class PluginBase : IPlugin
{
public virtual void Initialize(IPluginContext context)
{
}
}

View File

@@ -0,0 +1,66 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentContext
{
public PluginDesktopComponentContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
double cellSize)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
CellSize = Math.Max(1, cellSize);
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public string ComponentId { get; }
public string? PlacementId { get; }
public double CellSize { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,66 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentRegistration
{
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece",
string category = "Plugins",
int minWidthCells = 2,
int minHeightCells = 2,
bool allowDesktopPlacement = true,
bool allowStatusBarPlacement = false,
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
string? displayNameLocalizationKey = null,
Func<double, double>? cornerRadiusResolver = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentNullException.ThrowIfNull(controlFactory);
ComponentId = componentId.Trim();
DisplayName = displayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = iconKey.Trim();
Category = category.Trim();
MinWidthCells = Math.Max(1, minWidthCells);
MinHeightCells = Math.Max(1, minHeightCells);
AllowDesktopPlacement = allowDesktopPlacement;
AllowStatusBarPlacement = allowStatusBarPlacement;
ResizeMode = resizeMode;
CornerRadiusResolver = cornerRadiusResolver;
}
public string ComponentId { get; }
public string DisplayName { get; }
public string? DisplayNameLocalizationKey { get; }
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }
public string Category { get; }
public int MinWidthCells { get; }
public int MinHeightCells { get; }
public bool AllowDesktopPlacement { get; }
public bool AllowStatusBarPlacement { get; }
public PluginDesktopComponentResizeMode ResizeMode { get; }
public Func<double, double>? CornerRadiusResolver { get; }
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginDesktopComponentResizeMode
{
Proportional = 0,
Free = 1
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginEntranceAttribute : Attribute
{
}

View File

@@ -0,0 +1,66 @@
using System.Reflection;
using System.Runtime.Loader;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly HashSet<string> _sharedAssemblyNames;
public PluginLoadContext(string mainAssemblyPath, IEnumerable<string>? sharedAssemblyNames = null)
: base($"{Path.GetFileNameWithoutExtension(mainAssemblyPath)}_{Guid.NewGuid():N}", isCollectible: true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(mainAssemblyPath);
MainAssemblyPath = Path.GetFullPath(mainAssemblyPath);
_resolver = new AssemblyDependencyResolver(MainAssemblyPath);
_sharedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
typeof(IPlugin).Assembly.GetName().Name!
};
if (sharedAssemblyNames is null)
{
return;
}
foreach (var assemblyName in sharedAssemblyNames)
{
if (!string.IsNullOrWhiteSpace(assemblyName))
{
_sharedAssemblyNames.Add(assemblyName.Trim());
}
}
}
public string MainAssemblyPath { get; }
protected override Assembly? Load(AssemblyName assemblyName)
{
var simpleName = assemblyName.Name;
if (string.IsNullOrWhiteSpace(simpleName))
{
return null;
}
if (_sharedAssemblyNames.Contains(simpleName))
{
return Default.Assemblies.FirstOrDefault(
assembly => string.Equals(
assembly.GetName().Name,
simpleName,
StringComparison.OrdinalIgnoreCase))
?? null;
}
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath);
}
protected override nint LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return libraryPath is null ? nint.Zero : LoadUnmanagedDllFromPath(libraryPath);
}
}

View File

@@ -0,0 +1,20 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginLoadResult(
string SourcePath,
PluginManifest? Manifest,
LoadedPlugin? LoadedPlugin,
Exception? Error)
{
public bool IsSuccess => LoadedPlugin is not null && Error is null;
public static PluginLoadResult Success(string sourcePath, PluginManifest manifest, LoadedPlugin loadedPlugin)
{
return new PluginLoadResult(sourcePath, manifest, loadedPlugin, null);
}
public static PluginLoadResult Failure(string sourcePath, PluginManifest? manifest, Exception error)
{
return new PluginLoadResult(sourcePath, manifest, null, error);
}
}

View File

@@ -0,0 +1,616 @@
using System.Collections.ObjectModel;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginLoader
{
private readonly PluginLoaderOptions _options;
public PluginLoader(PluginLoaderOptions? options = null)
{
_options = options ?? new PluginLoaderOptions();
}
public IReadOnlyList<PluginLoadResult> LoadAll(
string pluginsRootDirectory,
IServiceProvider? services = null,
IReadOnlyDictionary<string, object?>? properties = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory);
if (!Directory.Exists(pluginsRootDirectory))
{
return Array.Empty<PluginLoadResult>();
}
var results = new List<PluginLoadResult>();
var candidates = DiscoverCandidates(pluginsRootDirectory, results);
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
if (!selectedPluginIds.Add(candidate.Manifest.Id))
{
results.Add(PluginLoadResult.Failure(
candidate.SourcePath,
candidate.Manifest,
new InvalidOperationException(
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected.")));
continue;
}
results.Add(candidate.SourceKind switch
{
PluginSourceKind.Package => LoadFromPackage(
candidate.SourcePath,
pluginsRootDirectory,
candidate.Manifest,
services,
properties),
_ => LoadFromManifest(
candidate.SourcePath,
candidate.Manifest,
services,
properties)
});
}
return results;
}
public PluginLoadResult LoadFromManifest(
string manifestPath,
IServiceProvider? services = null,
IReadOnlyDictionary<string, object?>? properties = null)
{
PluginManifest? manifest = null;
try
{
manifest = PluginManifest.Load(manifestPath);
return LoadFromManifest(manifestPath, manifest, services, properties);
}
catch (Exception ex)
{
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
}
}
public PluginLoadResult LoadFromPackage(
string packagePath,
string pluginsRootDirectory,
IServiceProvider? services = null,
IReadOnlyDictionary<string, object?>? properties = null)
{
PluginManifest? manifest = null;
try
{
manifest = ReadManifestFromPackage(packagePath);
return LoadFromPackage(packagePath, pluginsRootDirectory, manifest, services, properties);
}
catch (Exception ex)
{
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
}
}
public PluginLoadResult LoadFromAssembly(
string assemblyPath,
PluginManifest manifest,
IServiceProvider? services = null,
IReadOnlyDictionary<string, object?>? properties = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath);
ArgumentNullException.ThrowIfNull(manifest);
var fullAssemblyPath = Path.GetFullPath(assemblyPath);
var pluginDirectory = Path.GetDirectoryName(fullAssemblyPath)
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{fullAssemblyPath}'.");
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
return LoadCore(fullAssemblyPath, fullAssemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
}
private PluginLoadResult LoadCore(
string sourcePath,
string assemblyPath,
string pluginDirectory,
string dataDirectory,
PluginManifest manifest,
IServiceProvider? services,
IReadOnlyDictionary<string, object?>? properties)
{
PluginLoadContext? loadContext = null;
try
{
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
var pluginType = ResolvePluginType(assembly);
var plugin = CreatePluginInstance(pluginType);
var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
plugin.Initialize(context);
var settingsPages = context.GetSettingsPagesSnapshot();
var desktopComponents = context.GetDesktopComponentsSnapshot();
var loadedPlugin = new LoadedPlugin(
manifest,
sourcePath,
assemblyPath,
assembly,
plugin,
context,
settingsPages,
desktopComponents,
loadContext);
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
}
catch (Exception ex)
{
loadContext?.Unload();
return PluginLoadResult.Failure(sourcePath, manifest, ex);
}
}
private PluginLoadResult LoadFromManifest(
string manifestPath,
PluginManifest manifest,
IServiceProvider? services,
IReadOnlyDictionary<string, object?>? properties)
{
try
{
var fullManifestPath = Path.GetFullPath(manifestPath);
var assemblyPath = manifest.ResolveEntranceAssemblyPath(fullManifestPath);
if (!File.Exists(assemblyPath))
{
throw new FileNotFoundException(
$"Plugin '{manifest.Id}' entrance assembly '{assemblyPath}' was not found.",
assemblyPath);
}
var pluginDirectory = Path.GetDirectoryName(assemblyPath)
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{assemblyPath}'.");
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
return LoadCore(fullManifestPath, assemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
}
catch (Exception ex)
{
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
}
}
private PluginLoadResult LoadFromPackage(
string packagePath,
string pluginsRootDirectory,
PluginManifest manifest,
IServiceProvider? services,
IReadOnlyDictionary<string, object?>? properties)
{
try
{
var fullPackagePath = Path.GetFullPath(packagePath);
var extractionDirectory = ExtractPackage(fullPackagePath, pluginsRootDirectory);
var extractedManifestPath = Path.Combine(extractionDirectory, _options.ManifestFileName);
if (!File.Exists(extractedManifestPath))
{
throw new FileNotFoundException(
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.",
extractedManifestPath);
}
var extractedManifest = PluginManifest.Load(extractedManifestPath);
if (!string.Equals(extractedManifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Plugin package '{fullPackagePath}' manifest id changed after extraction. Expected '{manifest.Id}', actual '{extractedManifest.Id}'.");
}
var assemblyPath = extractedManifest.ResolveEntranceAssemblyPath(extractedManifestPath);
if (!File.Exists(assemblyPath))
{
throw new FileNotFoundException(
$"Plugin '{extractedManifest.Id}' entrance assembly '{assemblyPath}' was not found after package extraction.",
assemblyPath);
}
var dataDirectory = GetPackagedDataDirectory(pluginsRootDirectory, extractedManifest);
return LoadCore(fullPackagePath, assemblyPath, extractionDirectory, dataDirectory, extractedManifest, services, properties);
}
catch (Exception ex)
{
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
}
}
private PluginContext CreateContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider? services,
IReadOnlyDictionary<string, object?>? properties)
{
Directory.CreateDirectory(dataDirectory);
return new PluginContext(
manifest,
pluginDirectory,
dataDirectory,
services ?? NullServiceProvider.Instance,
CreateReadOnlyProperties(properties));
}
private IReadOnlyList<PluginCandidate> DiscoverCandidates(
string pluginsRootDirectory,
List<PluginLoadResult> preparationFailures)
{
var candidates = new List<PluginCandidate>();
foreach (var packagePath in EnumerateCandidatePaths(
pluginsRootDirectory,
"*" + NormalizePackageExtension(_options.PackageFileExtension)))
{
try
{
var manifest = ReadManifestFromPackage(packagePath);
candidates.Add(new PluginCandidate(Path.GetFullPath(packagePath), manifest, PluginSourceKind.Package));
}
catch (Exception ex)
{
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(packagePath), null, ex));
}
}
foreach (var manifestPath in EnumerateCandidatePaths(pluginsRootDirectory, _options.ManifestFileName))
{
try
{
var manifest = PluginManifest.Load(manifestPath);
candidates.Add(new PluginCandidate(Path.GetFullPath(manifestPath), manifest, PluginSourceKind.Manifest));
}
catch (Exception ex)
{
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(manifestPath), null, ex));
}
}
return candidates
.OrderBy(candidate => candidate.SourceKind)
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private IEnumerable<string> EnumerateCandidatePaths(string pluginsRootDirectory, string searchPattern)
{
var runtimeRootDirectory = EnsureTrailingSeparator(GetRuntimeRootDirectory(pluginsRootDirectory));
return Directory
.EnumerateFiles(pluginsRootDirectory, searchPattern, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
}
private PluginManifest ReadManifestFromPackage(string packagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
var fullPackagePath = Path.GetFullPath(packagePath);
if (!File.Exists(fullPackagePath))
{
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
}
using var archive = ZipFile.OpenRead(fullPackagePath);
var manifestEntries = archive.Entries
.Where(entry =>
!string.IsNullOrWhiteSpace(entry.Name) &&
string.Equals(entry.Name, _options.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (manifestEntries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.");
}
if (manifestEntries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{fullPackagePath}' contains multiple '{_options.ManifestFileName}' files.");
}
using var stream = manifestEntries[0].Open();
return PluginManifest.Load(stream, $"{fullPackagePath}!/{manifestEntries[0].FullName}");
}
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
{
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
RecreateDirectory(extractionDirectory);
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
return extractionDirectory;
}
private string GetPackageExtractionDirectory(string pluginsRootDirectory, string packagePath)
{
var packageName = SanitizeDirectoryName(Path.GetFileNameWithoutExtension(packagePath));
var packageHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(packagePath))))
.Substring(0, 12);
return Path.Combine(
GetRuntimeRootDirectory(pluginsRootDirectory),
_options.ExtractedPackagesDirectoryName,
$"{packageName}_{packageHash}");
}
private string GetPackagedDataDirectory(string pluginsRootDirectory, PluginManifest manifest)
{
return Path.Combine(
GetRuntimeRootDirectory(pluginsRootDirectory),
_options.PackagedDataDirectoryName,
SanitizeDirectoryName(manifest.Id));
}
private string GetRuntimeRootDirectory(string pluginsRootDirectory)
{
return Path.Combine(Path.GetFullPath(pluginsRootDirectory), _options.RuntimeDirectoryName);
}
private static void RecreateDirectory(string directoryPath)
{
if (Directory.Exists(directoryPath))
{
Directory.Delete(directoryPath, recursive: true);
}
Directory.CreateDirectory(directoryPath);
}
private static string NormalizePackageExtension(string extension)
{
ArgumentException.ThrowIfNullOrWhiteSpace(extension);
return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension;
}
private static string EnsureTrailingSeparator(string path)
{
var fullPath = Path.GetFullPath(path);
return fullPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? fullPath
: fullPath + Path.DirectorySeparatorChar;
}
private static string SanitizeDirectoryName(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
}
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
}
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
IReadOnlyDictionary<string, object?>? properties)
{
if (properties is null || properties.Count == 0)
{
return new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
}
var map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in properties)
{
map[pair.Key] = pair.Value;
}
return new ReadOnlyDictionary<string, object?>(map);
}
private static Type ResolvePluginType(Assembly assembly)
{
var candidateTypes = GetLoadableTypes(assembly)
.Where(type =>
typeof(IPlugin).IsAssignableFrom(type) &&
!type.IsAbstract &&
!type.IsInterface &&
!type.ContainsGenericParameters)
.ToArray();
if (candidateTypes.Length == 0)
{
throw new InvalidOperationException(
$"Assembly '{assembly.Location}' does not contain a concrete type implementing '{nameof(IPlugin)}'.");
}
var attributedTypes = candidateTypes
.Where(type => type.IsDefined(typeof(PluginEntranceAttribute), inherit: false))
.ToArray();
if (attributedTypes.Length == 1)
{
return attributedTypes[0];
}
if (attributedTypes.Length > 1)
{
throw new InvalidOperationException(
$"Assembly '{assembly.Location}' contains multiple plugin entrance types. Mark only one type with '{nameof(PluginEntranceAttribute)}'.");
}
if (candidateTypes.Length == 1)
{
return candidateTypes[0];
}
throw new InvalidOperationException(
$"Assembly '{assembly.Location}' contains multiple '{nameof(IPlugin)}' implementations. Mark the intended entrance type with '{nameof(PluginEntranceAttribute)}'.");
}
private static IPlugin CreatePluginInstance(Type pluginType)
{
if (pluginType.GetConstructor(Type.EmptyTypes) is null)
{
throw new InvalidOperationException(
$"Plugin type '{pluginType.FullName}' must expose a public parameterless constructor.");
}
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
{
throw new InvalidOperationException(
$"Failed to create plugin instance of type '{pluginType.FullName}'.");
}
return plugin;
}
private static Type[] GetLoadableTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
var loaderMessages = ex.LoaderExceptions
.Where(exception => exception is not null)
.Select(exception => exception!.Message)
.ToArray();
var detail = loaderMessages.Length == 0
? "No additional loader diagnostics were provided."
: string.Join(Environment.NewLine, loaderMessages);
throw new InvalidOperationException(
$"Failed to inspect plugin assembly '{assembly.Location}'.{Environment.NewLine}{detail}",
ex);
}
}
private sealed class PluginContext : IPluginContext
{
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
new(StringComparer.OrdinalIgnoreCase);
public PluginContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties)
{
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
if (!_settingsPages.TryAdd(registration.Id, registration))
{
throw new InvalidOperationException(
$"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'.");
}
}
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
{
throw new InvalidOperationException(
$"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'.");
}
}
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
{
return _settingsPages.Values
.OrderBy(page => page.SortOrder)
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
{
return _desktopComponents.Values
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
private sealed class NullServiceProvider : IServiceProvider
{
public static NullServiceProvider Instance { get; } = new();
private NullServiceProvider()
{
}
public object? GetService(Type serviceType)
{
return null;
}
}
private enum PluginSourceKind
{
Package = 0,
Manifest = 1
}
private sealed record PluginCandidate(
string SourcePath,
PluginManifest Manifest,
PluginSourceKind SourceKind);
}

View File

@@ -0,0 +1,21 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginLoaderOptions
{
public string ManifestFileName { get; init; } = PluginSdkInfo.ManifestFileName;
public string PackageFileExtension { get; init; } = PluginSdkInfo.PackageFileExtension;
public string DataDirectoryName { get; init; } = PluginSdkInfo.DataDirectoryName;
public string RuntimeDirectoryName { get; init; } = PluginSdkInfo.RuntimeDirectoryName;
public string ExtractedPackagesDirectoryName { get; init; } = PluginSdkInfo.ExtractedPackagesDirectoryName;
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
typeof(IPlugin).Assembly.GetName().Name!
};
}

View File

@@ -0,0 +1,107 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginManifest(
string Id,
string Name,
string EntranceAssembly,
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static PluginManifest Load(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
using var stream = File.OpenRead(manifestPath);
return Load(stream, manifestPath);
}
public static PluginManifest Load(Stream stream, string sourceName)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
if (manifest is null)
{
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
}
return manifest.NormalizeAndValidate(sourceName);
}
public string ResolveEntranceAssemblyPath(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
if (Path.IsPathRooted(EntranceAssembly))
{
return Path.GetFullPath(EntranceAssembly);
}
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
}
private PluginManifest NormalizeAndValidate(string manifestPath)
{
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
Name = RequireValue(Name, nameof(Name), manifestPath),
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
};
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
}
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
{
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
}
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
}
return normalized;
}
private static string RequireValue(string? value, string propertyName, string manifestPath)
{
var normalized = NormalizeOptionalValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
}
return normalized;
}
private static string? NormalizeOptionalValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "1.0.0";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";
public const string RuntimeDirectoryName = ".runtime";
public const string ExtractedPackagesDirectoryName = "packages";
public const string PackagedDataDirectoryName = "data";
}

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsPageRegistration
{
public PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentNullException.ThrowIfNull(contentFactory);
Id = id.Trim();
Title = title.Trim();
ContentFactory = contentFactory;
SortOrder = sortOrder;
}
public string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<Control> ContentFactory { get; }
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
</Target>
</Project>

View File

@@ -0,0 +1,66 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginHeartbeatService? _heartbeatService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var hostName = context.TryGetProperty<string>("HostApplicationName", out var configuredHostName) &&
!string.IsNullOrWhiteSpace(configuredHostName)
? configuredHostName
: "UnknownHost";
var version = context.Manifest.Version ?? "dev";
SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory);
var message =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version}).";
try
{
File.AppendAllText(
Path.Combine(context.DataDirectory, "sample-plugin.log"),
message + Environment.NewLine);
SamplePluginRuntimeStatus.MarkBackendReady(
$"Plugin entry initialized successfully. Host: {hostName}; Version: {version}");
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
throw;
}
_heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory);
_heartbeatService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
"Plugin Status",
() => new SamplePluginSettingsView(context)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
"Sample Plugin Status Clock",
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: "Plugins",
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
}
public void Dispose()
{
_heartbeatService?.Dispose();
_heartbeatService = null;
}
}

View File

@@ -0,0 +1,251 @@
using System.Globalization;
using System.IO;
using System.Threading;
namespace LanMountainDesktop.SamplePlugin;
internal enum SamplePluginHealthState
{
Healthy,
Pending,
Faulted
}
internal sealed record SamplePluginStatusEntry(
string Key,
string Title,
SamplePluginHealthState State,
string Summary,
string Detail,
DateTimeOffset UpdatedAt);
internal static class SamplePluginRuntimeStatus
{
private static readonly object Gate = new();
private static SamplePluginStatusEntry _frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Frontend surfaces have not been created yet.");
private static SamplePluginStatusEntry _component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
private static SamplePluginStatusEntry _backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Pending,
"Pending",
"Plugin initialization has not finished yet.");
private static SamplePluginStatusEntry _service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service has not started yet.");
public static void Reset(string hostName, string version, string dataDirectory)
{
lock (Gate)
{
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Waiting for the settings page or widget surface to render.");
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
$"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}");
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service is starting.");
}
}
public static void MarkFrontendReady(string detail)
{
lock (Gate)
{
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
}
}
public static void MarkComponentCreated(string detail)
{
lock (Gate)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Created",
detail);
}
}
public static void MarkBackendReady(string detail)
{
lock (Gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
}
}
public static void MarkBackendFaulted(string detail)
{
lock (Gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static void MarkServiceHeartbeat(DateTimeOffset timestamp)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Healthy,
"Healthy",
$"Heartbeat service is running. Last heartbeat: {timestamp.LocalDateTime:HH:mm:ss}");
}
}
public static void MarkServiceFaulted(string detail)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static IReadOnlyList<SamplePluginStatusEntry> GetSnapshot()
{
lock (Gate)
{
return
[
_frontend,
_component,
_backend,
_service
];
}
}
private static SamplePluginStatusEntry CreateEntry(
string key,
string title,
SamplePluginHealthState state,
string summary,
string detail)
{
return new SamplePluginStatusEntry(
key,
title,
state,
summary,
detail,
DateTimeOffset.Now);
}
}
internal sealed class SamplePluginHeartbeatService : IDisposable
{
private readonly string _heartbeatFilePath;
private readonly Timer _timer;
private int _disposed;
public SamplePluginHeartbeatService(string dataDirectory)
{
Directory.CreateDirectory(dataDirectory);
_heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt");
_timer = new Timer(OnTimerTick);
}
public void Start()
{
PublishHeartbeat();
_timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_timer.Dispose();
}
private void OnTimerTick(object? state)
{
PublishHeartbeat();
}
private void PublishHeartbeat()
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
var now = DateTimeOffset.Now;
try
{
File.WriteAllText(
_heartbeatFilePath,
now.ToString("O", CultureInfo.InvariantCulture));
SamplePluginRuntimeStatus.MarkServiceHeartbeat(now);
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkServiceFaulted($"Heartbeat write failed: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,190 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginSettingsView : UserControl
{
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly IPluginContext _context;
private readonly TextBlock _summaryTextBlock;
private readonly StackPanel _statusPanel;
public SamplePluginSettingsView(IPluginContext context)
{
_context = context;
_summaryTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 10
};
SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully.");
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
Content = new Border
{
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#1F0B1120"), 0),
new GradientStop(Color.Parse("#260C4A6E"), 1)
]
},
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(18),
Child = new StackPanel
{
Spacing = 14,
Children =
{
new TextBlock
{
Text = "Sample Plugin Runtime Status",
FontSize = 22,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
_summaryTextBlock,
new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = _statusPanel
}
}
}
};
RefreshStatuses();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshStatuses();
_refreshTimer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_refreshTimer.Stop();
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
{
RefreshStatuses();
}
private void RefreshStatuses()
{
_summaryTextBlock.Text =
$"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}";
_statusPanel.Children.Clear();
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
}
}
},
new TextBlock
{
Text = entry.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}",
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
}
}
}
});
var row = (Grid)((StackPanel)((Border)_statusPanel.Children[^1]).Child!).Children[0];
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
}
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F115E59"),
Color.Parse("#665EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#291B1B"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#2B3A2A0D"),
Color.Parse("#66FBBF24"),
Color.Parse("#FBBF24"))
};
}
}

View File

@@ -0,0 +1,227 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginStatusClockWidget : Border
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly PluginDesktopComponentContext _context;
private readonly TextBlock _timeTextBlock;
private readonly TextBlock _titleTextBlock;
private readonly StackPanel _statusPanel;
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
{
_context = context;
_timeTextBlock = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Left
};
_titleTextBlock = new TextBlock
{
Text = "Plugin Status",
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
HorizontalAlignment = HorizontalAlignment.Left
};
_statusPanel = new StackPanel
{
Spacing = 8
};
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF07111F"), 0),
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
BorderThickness = new Thickness(1);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 14,
Children =
{
new StackPanel
{
Spacing = 4,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
_timeTextBlock,
_titleTextBlock
}
},
new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(12),
Child = _statusPanel
}
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
var placementText = string.IsNullOrWhiteSpace(context.PlacementId)
? "Preview instance created."
: $"Widget created for placement {context.PlacementId}.";
SamplePluginRuntimeStatus.MarkFrontendReady("Widget frontend surface rendered successfully.");
SamplePluginRuntimeStatus.MarkComponentCreated($"{placementText} Baseline footprint: 4x4.");
RefreshClock();
RefreshStatusPanel();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshClock();
RefreshStatusPanel();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
private void OnTimerTick(object? sender, EventArgs e)
{
RefreshClock();
RefreshStatusPanel();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
RefreshStatusPanel();
}
private void RefreshClock()
{
_timeTextBlock.Text = DateTime.Now.ToString("HH:mm:ss");
}
private void RefreshStatusPanel()
{
_statusPanel.Children.Clear();
var basis = GetLayoutBasis();
var titleSize = Math.Clamp(basis * 0.072, 11, 16);
var detailSize = Math.Clamp(basis * 0.055, 10, 13);
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
{
var palette = GetPalette(entry.State);
var summaryText = $"{entry.Summary} - {entry.UpdatedAt.LocalDateTime:HH:mm:ss}";
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(10, 8),
Child = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = Math.Clamp(basis * 0.038, 8, 11),
Height = Math.Clamp(basis * 0.038, 8, 11),
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = titleSize,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = summaryText,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right,
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
}
}
}
});
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
}
}
private void ApplyScale()
{
var basis = GetLayoutBasis();
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
_titleTextBlock.FontSize = Math.Clamp(basis * 0.07, 12, 18);
}
private double GetLayoutBasis()
{
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F0F766E"),
Color.Parse("#4D5EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#29B91C1C"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#1F7C2D12"),
Color.Parse("#66FDBA74"),
Color.Parse("#FDBA74"))
};
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "LanMountainDesktop.SamplePlugin",
"name": "LanMountain Sample Plugin",
"description": "Example plugin used to validate PluginSdk loading and isolation.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
}

View File

@@ -5,6 +5,10 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,5 +19,13 @@ Global
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -15,10 +15,28 @@
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="LanMountainDesktop">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="设置" Click="OnTraySettingsClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />

View File

@@ -2,8 +2,12 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Diagnostics;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
using AvaloniaWebView;
@@ -12,19 +16,29 @@ namespace LanMountainDesktop;
public partial class App : Application
{
private SettingsWindow? _traySettingsWindow;
private PluginRuntimeService? _pluginRuntimeService;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public override void Initialize()
{
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
@@ -34,6 +48,95 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
private void OnTraySettingsClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
try
{
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
{
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
existingWindow.Activate();
return;
}
var settingsWindow = new SettingsWindow();
settingsWindow.Closed += (_, _) =>
{
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
{
_traySettingsWindow = null;
}
};
_traySettingsWindow = settingsWindow;
settingsWindow.Show();
settingsWindow.Activate();
}
catch (Exception ex)
{
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
}
}, DispatcherPriority.Normal);
}
private void OnTrayRestartClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
if (TryStartCurrentProcess())
{
desktop.Shutdown();
}
}
private static bool TryStartCurrentProcess()
{
try
{
var args = Environment.GetCommandLineArgs();
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
{
return false;
}
var startInfo = new ProcessStartInfo
{
FileName = args[0],
UseShellExecute = false
};
for (var i = 1; i < args.Length; i++)
{
startInfo.ArgumentList.Add(args[i]);
}
Process.Start(startInfo);
return true;
}
catch
{
return false;
}
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -46,4 +149,45 @@ public partial class App : Application
BindingPlugins.DataValidators.Remove(plugin);
}
}
private static void ConfigureWebViewUserDataFolder()
{
if (!OperatingSystem.IsWindows())
{
return;
}
const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER";
try
{
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar)))
{
return;
}
var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder();
Environment.SetEnvironmentVariable(
userDataFolderEnvVar,
userDataFolder,
EnvironmentVariableTarget.Process);
}
catch
{
// Keep startup resilient if user profile folders are unavailable.
}
}
private void InitializePluginRuntime()
{
try
{
_pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService();
_pluginRuntimeService.LoadInstalledPlugins();
}
catch (Exception ex)
{
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
}
}
}

View File

@@ -36,3 +36,20 @@ Extracted weather icon paths inside APK (`res/*.webp`):
- `res/Mg.webp` -> `Icons/icon_windy.webp`
Use only according to Xiaomi's applicable license and usage terms.
## Soft Widget Icon Set (2026-03-05)
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
- `Icons/icon_hero_sun_soft.png`
- `Icons/icon_hero_moon_soft.png`
- `Icons/icon_mini_partly_cloudy_day_soft.png`
- `Icons/icon_mini_partly_cloudy_night_soft.png`
- `Icons/icon_mini_cloudy_soft.png`
- `Icons/icon_mini_rain_light_soft.png`
- `Icons/icon_mini_rain_heavy_soft.png`
- `Icons/icon_mini_storm_soft.png`
- `Icons/icon_mini_snow_soft.png`
- `Icons/icon_mini_fog_soft.png`
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -3,6 +3,7 @@ using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Behaviors;
@@ -109,7 +110,7 @@ public class PanelIntroAnimationBehavior
var index = 0;
var timer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromMilliseconds(24)
Interval = FluttermotionToken.StaggerStepInterval
};
timer.Tick += (_, _) =>
{

View File

@@ -4,11 +4,14 @@ using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Rendering.Composition;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Behaviors;
public class PopupIntroAnimationBehavior
{
private static readonly Easing StandardEasing = Easing.Parse(FluttermotionToken.StandardBezier);
public static readonly AttachedProperty<bool> IsEnabledProperty =
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("IsEnabled");
@@ -94,16 +97,16 @@ public class PopupIntroAnimationBehavior
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
opacityAnimation.Target = nameof(compositionVisual.Opacity);
opacityAnimation.Duration = TimeSpan.FromMilliseconds(160);
opacityAnimation.Duration = FluttermotionToken.Standard;
opacityAnimation.InsertKeyFrame(0f, 0f);
opacityAnimation.InsertKeyFrame(1f, 1f, Easing.Parse("0.22, 1, 0.36, 1"));
opacityAnimation.InsertKeyFrame(1f, 1f, StandardEasing);
compositionVisual.StartAnimation(nameof(compositionVisual.Opacity), opacityAnimation);
var scaleAnimation = compositor.CreateVector3DKeyFrameAnimation();
scaleAnimation.Target = nameof(compositionVisual.Scale);
scaleAnimation.Duration = TimeSpan.FromMilliseconds(160);
scaleAnimation.Duration = FluttermotionToken.Standard;
scaleAnimation.InsertKeyFrame(0f, compositionVisual.Scale with { X = 0.94, Y = 0.94 });
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, Easing.Parse("0.22, 1, 0.36, 1"));
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, StandardEasing);
compositionVisual.StartAnimation(nameof(compositionVisual.Scale), scaleAnimation);
}

View File

@@ -5,6 +5,7 @@ public static class BuiltInComponentIds
public const string Clock = "Clock";
public const string DesktopClock = "DesktopClock";
public const string DesktopWeatherClock = "DesktopWeatherClock";
public const string DesktopWorldClock = "DesktopWorldClock";
public const string DesktopTimer = "DesktopTimer";
public const string DesktopWeather = "DesktopWeather";
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
@@ -28,6 +29,14 @@ public static class BuiltInComponentIds
public const string HolidayCalendar = "HolidayCalendar";
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
public const string DesktopDailyWord = "DesktopDailyWord";
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";

View File

@@ -48,6 +48,15 @@ public sealed class ComponentRegistry
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWorldClock,
"World Clock",
"Clock",
"Clock",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopTimer,
"Timer",
@@ -216,6 +225,78 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyWord,
"Daily Word",
"Book",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyWord2x2,
"Daily Word 2x2",
"Book",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopCnrDailyNews,
"CNR Daily News",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopIfengNews,
"iFeng News",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBaiduHotSearch,
"Baidu Hot Search",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStcn24Forum,
"STCN 24",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopExchangeRateCalculator,
"Exchange Rate Converter",
"Calculator",
"Calculator",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWhiteboard,
"Blackboard Portrait",
@@ -304,6 +385,13 @@ public sealed class ComponentRegistry
return new ComponentRegistry(merged);
}
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
{
var merged = _definitions.Values.ToList();
merged.AddRange(definitions);
return new ComponentRegistry(merged);
}
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
{
return _definitions.TryGetValue(componentId, out definition!);

View File

@@ -0,0 +1,8 @@
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentRuntimeContext(
string ComponentId,
string? PlacementId,
IComponentInstanceSettingsStore ComponentSettingsStore);

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentPlacementContextAware
{
void SetComponentPlacementContext(string componentId, string? placementId);
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentRuntimeContextAware
{
void SetComponentRuntimeContext(DesktopComponentRuntimeContext context);
}

View File

@@ -0,0 +1,8 @@
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentSettingsStoreAware
{
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
}

View File

@@ -6,30 +6,17 @@
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Release build optimizations -->
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
</PropertyGroup>
<!-- Release build optimizations for smaller, faster packages -->
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<PublishReadyToRun>true</PublishReadyToRun>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup>
<!-- Self-contained runtime settings -->
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
@@ -37,6 +24,10 @@
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<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" />

View File

@@ -12,6 +12,9 @@
"settings.nav.status_bar": "Status Bar",
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
"settings.nav.launcher": "App Launcher",
"settings.nav.plugins": "Plugins",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
@@ -162,6 +165,21 @@
"schedule.settings.delete": "Delete",
"schedule.settings.picker_title": "Select ClassIsland schedule file",
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
"worldclock.settings.title": "World Clock Settings",
"worldclock.settings.desc": "Choose a time zone for each of the four clocks.",
"worldclock.settings.clock_1": "Clock 1",
"worldclock.settings.clock_2": "Clock 2",
"worldclock.settings.clock_3": "Clock 3",
"worldclock.settings.clock_4": "Clock 4",
"worldclock.settings.second_mode_label": "Second Hand",
"worldclock.widget.today": "Today",
"worldclock.widget.yesterday": "Yesterday",
"worldclock.widget.tomorrow": "Tomorrow",
"worldclock.widget.offset_same": "0h",
"worldclock.widget.offset_ahead_hours": "Ahead {0}h",
"worldclock.widget.offset_behind_hours": "Behind {0}h",
"worldclock.widget.offset_ahead_hm": "Ahead {0}h {1}m",
"worldclock.widget.offset_behind_hm": "Behind {0}h {1}m",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "Updated {0:HH:mm}",
@@ -180,10 +198,45 @@
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
"settings.update.title": "Update",
"settings.update.current_version_label": "Current Version",
"settings.update.latest_version_label": "Latest Release",
"settings.update.published_at_label": "Published At",
"settings.update.options_header": "Update Options",
"settings.update.options_desc": "Configure update checks and release channel.",
"settings.update.auto_check_toggle": "Automatically check for updates on startup",
"settings.update.include_prerelease_toggle": "Include prerelease versions",
"settings.update.channel_label": "Update Channel",
"settings.update.channel_stable": "Stable",
"settings.update.channel_preview": "Preview",
"settings.update.actions_header": "Update Actions",
"settings.update.actions_desc": "Check releases, download installer, and start update.",
"settings.update.check_button": "Check for Updates",
"settings.update.download_install_button": "Download & Install",
"settings.update.download_progress_idle": "Download progress: -",
"settings.update.download_progress_format": "Download progress: {0:F0}%",
"settings.update.status_ready": "Ready to check for updates.",
"settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...",
"settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
"settings.update.status_downloading": "Downloading installer...",
"settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
"settings.about.title": "About",
"settings.about.version_format": "Version: {0}",
"settings.about.codename_format": "Code Name: {0}",
"settings.about.font_format": "Font: {0}",
"settings.about.startup_header": "Windows Startup",
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
"settings.about.startup_toggle": "Launch at Windows sign-in",
"settings.footer": "LanMountainDesktop Settings",
"filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files",
@@ -192,14 +245,53 @@
"common.night": "Night",
"common.back": "Back",
"common.close": "Close",
"common.unknown": "Unknown error",
"common.recommended": "Recommended",
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
"launcher.empty_linux": "No Linux desktop entries were found.",
"launcher.empty_folder": "This folder is empty.",
"launcher.folder_items_format": "{0} apps",
"launcher.context.hide_icon": "Hide Icon",
"launcher.action.hide": "Hide",
"settings.launcher.title": "App Launcher",
"settings.launcher.hidden_header": "Hidden Items",
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
"settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.restore_button": "Show Again",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
"settings.plugins.installed_header": "Installed Plugins",
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
"settings.plugins.empty": "No plugins found.",
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
"settings.plugins.state.enabled": "Enabled",
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
"settings.plugins.state.disabled": "Disabled",
"settings.plugins.state.loaded": "Loaded",
"settings.plugins.state.load_failed": "Load failed",
"settings.plugins.toggle_on": "Enabled",
"settings.plugins.toggle_off": "Disabled",
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
"settings.plugins.toggle_state_enabled": "enabled",
"settings.plugins.toggle_state_disabled": "disabled",
"settings.plugins.source_package": ".laapp package",
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
@@ -213,12 +305,14 @@
"component_category.board": "Board",
"component_category.media": "Media",
"component_category.info": "Info",
"component_category.calculator": "Calculator",
"component_category.study": "Study",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
"component.desktop_clock": "Clock",
"component.weather_clock": "Weather Clock",
"component.world_clock": "World Clock",
"component.desktop_timer": "Timer",
"component.desktop_weather": "Weather",
"component.hourly_weather": "Hourly Weather",
@@ -229,6 +323,14 @@
"component.audio_recorder": "Recorder",
"component.daily_poetry": "Daily Poetry",
"component.daily_artwork": "Daily Artwork",
"component.daily_word": "Daily Word",
"component.daily_word_2x2": "Daily Word 2x2",
"component.cnr_daily_news": "CNR Headlines",
"component.ifeng_news": "iFeng News",
"component.bilibili_hot_search": "Bilibili Hot Search",
"component.baidu_hot_search": "Baidu Hot Search",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "Exchange Rate Converter",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
@@ -241,6 +343,12 @@
"component.study_score_overview": "Study Score Overview",
"component.study_deduction_reasons": "Deduction Reasons",
"component.study_interrupt_density": "Interrupt Density",
"desktop_clock.settings.title": "Clock Settings",
"desktop_clock.settings.desc": "Choose the time zone for the single clock.",
"desktop_clock.settings.timezone_label": "Time Zone",
"desktop_clock.settings.second_mode_label": "Second Hand",
"clock.second_mode.tick": "Tick",
"clock.second_mode.sweep": "Sweep",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",
@@ -255,6 +363,141 @@
"artwork.widget.fallback_artist": "Recommendation service unavailable",
"artwork.widget.fallback_year": "Try again later",
"artwork.widget.unknown_artist": "Unknown artist",
"dailyword.widget.loading": "Loading...",
"dailyword.widget.loading_word": "daily word",
"dailyword.widget.loading_pronunciation": "Fetching pronunciation...",
"dailyword.widget.loading_meaning": "Fetching meaning...",
"dailyword.widget.loading_example": "Fetching example sentence...",
"dailyword.widget.loading_example_translation": "Loading...",
"dailyword.widget.fetch_failed": "Daily word fetch failed",
"dailyword.widget.fallback_word": "daily word",
"dailyword.widget.fallback_pronunciation": "Pronunciation unavailable",
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
"dailyword2x2.widget.tap_to_show": "Tap to reveal meaning",
"cnrnews.widget.loading": "Loading...",
"cnrnews.widget.loading_title": "Fetching CNR headlines",
"cnrnews.widget.loading_subtitle": "Please wait",
"cnrnews.widget.fetch_failed": "News fetch failed",
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
"cnrnews.widget.hot_label": "Hot",
"bilihot.widget.brand": "bilibili hot search",
"bilihot.widget.top_right_label": "bilibili热搜",
"bilihot.widget.search_entry": "Search",
"bilihot.widget.search_placeholder": "Search trending topics",
"bilihot.widget.loading": "Loading...",
"bilihot.widget.loading_item": "Loading...",
"bilihot.widget.fetch_failed": "Hot search fetch failed",
"bilihot.widget.fallback_item": "No hot search data",
"bilihot.widget.more_hot": "More hot search",
"baiduhot.widget.brand": "Baidu Hot Search",
"baiduhot.widget.loading": "Loading...",
"baiduhot.widget.loading_item": "Loading...",
"baiduhot.widget.fetch_failed": "Hot search fetch failed",
"baiduhot.widget.fallback_item": "No hot search data",
"baiduhot.widget.refresh_tooltip": "Refresh",
"ifeng.widget.brand": "iFeng News",
"ifeng.widget.loading": "Loading...",
"ifeng.widget.loading_item": "Loading...",
"ifeng.widget.fetch_failed": "News fetch failed",
"ifeng.widget.fallback_item": "No news data",
"ifeng.widget.refresh_tooltip": "Refresh",
"dailyword.settings.title": "Daily word settings",
"dailyword.settings.desc": "Configure auto refresh and refresh interval.",
"dailyword.settings.auto_refresh_label": "Auto refresh",
"dailyword.settings.auto_refresh_enabled": "Enable auto refresh",
"dailyword.settings.frequency_label": "Refresh interval",
"bilihot.settings.title": "Bilibili hot search settings",
"bilihot.settings.desc": "Configure auto refresh and refresh interval.",
"bilihot.settings.auto_refresh_label": "Auto refresh",
"bilihot.settings.auto_refresh_enabled": "Enable auto refresh",
"bilihot.settings.frequency_label": "Refresh interval",
"baiduhot.settings.title": "Baidu hot search settings",
"baiduhot.settings.desc": "Configure source, auto refresh and refresh interval.",
"baiduhot.settings.source_label": "Data source",
"baiduhot.settings.source_official": "Official Source",
"baiduhot.settings.source_rss": "Third-party RSS",
"baiduhot.settings.auto_refresh_label": "Auto refresh",
"baiduhot.settings.auto_refresh_enabled": "Enable auto refresh",
"baiduhot.settings.frequency_label": "Refresh interval",
"ifeng.settings.title": "iFeng news settings",
"ifeng.settings.desc": "Configure channel, auto refresh and refresh interval.",
"ifeng.settings.channel_label": "News channel",
"ifeng.settings.channel_comprehensive": "Comprehensive",
"ifeng.settings.channel_mainland": "China Mainland",
"ifeng.settings.channel_taiwan": "Taiwan",
"ifeng.settings.auto_refresh_label": "Auto refresh",
"ifeng.settings.auto_refresh_enabled": "Enable auto refresh",
"ifeng.settings.frequency_label": "Refresh interval",
"refresh.frequency.5m": "5 minutes",
"refresh.frequency.10m": "10 minutes",
"refresh.frequency.12m": "12 minutes",
"refresh.frequency.15m": "15 minutes",
"refresh.frequency.20m": "20 minutes",
"refresh.frequency.30m": "30 minutes",
"refresh.frequency.40m": "40 minutes",
"refresh.frequency.1h": "1 hour",
"refresh.frequency.3h": "3 hours",
"refresh.frequency.6h": "6 hours",
"refresh.frequency.12h": "12 hours",
"refresh.frequency.24h": "24 hours",
"weather.widget.settings.title": "Weather widget settings",
"weather.widget.settings.desc": "Configure auto refresh and refresh interval for all weather widgets.",
"weather.widget.settings.auto_refresh_label": "Auto refresh",
"weather.widget.settings.auto_refresh_enabled": "Enable auto refresh",
"weather.widget.settings.frequency_label": "Refresh interval",
"weather.widget.settings.frequency_10m": "10 minutes",
"weather.widget.settings.frequency_12m": "12 minutes",
"weather.widget.settings.frequency_15m": "15 minutes",
"weather.widget.settings.frequency_30m": "30 minutes",
"weather.widget.settings.frequency_1h": "1 hour",
"weather.widget.settings.frequency_3h": "3 hours",
"stcn24.widget.loading": "Loading...",
"stcn24.widget.loading_item": "Loading...",
"stcn24.widget.fetch_failed": "Forum posts fetch failed",
"stcn24.widget.fallback_item": "No posts",
"stcn24.settings.title": "STCN 24 settings",
"stcn24.settings.desc": "Configure information source, auto refresh and refresh interval.",
"stcn24.settings.source_label": "Information source",
"stcn24.settings.source_latest_created": "Latest posts",
"stcn24.settings.source_latest_activity": "Latest activity",
"stcn24.settings.source_most_replies": "Most replies",
"stcn24.settings.source_earliest_created": "Earliest posts",
"stcn24.settings.source_earliest_activity": "Earliest activity",
"stcn24.settings.source_least_replies": "Least replies",
"stcn24.settings.source_frontpage_latest": "Frontpage latest",
"stcn24.settings.source_frontpage_earliest": "Frontpage earliest",
"stcn24.settings.auto_refresh_label": "Auto refresh",
"stcn24.settings.auto_refresh_enabled": "Enable auto refresh",
"stcn24.settings.frequency_label": "Refresh interval",
"stcn24.settings.frequency_5m": "5 minutes",
"stcn24.settings.frequency_10m": "10 minutes",
"stcn24.settings.frequency_20m": "20 minutes",
"stcn24.settings.frequency_30m": "30 minutes",
"stcn24.settings.frequency_1h": "1 hour",
"stcn24.settings.frequency_3h": "3 hours",
"exchange.widget.loading": "Loading exchange rates...",
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
"cnrnews.settings.title": "CNR Settings",
"cnrnews.settings.desc": "Configure auto-rotation and refresh interval.",
"cnrnews.settings.auto_rotate_label": "Auto-rotation",
"cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation",
"cnrnews.settings.frequency_label": "Rotation interval",
"cnrnews.settings.frequency_5m": "5 minutes",
"cnrnews.settings.frequency_10m": "10 minutes",
"cnrnews.settings.frequency_40m": "40 minutes",
"cnrnews.settings.frequency_1h": "1 hour",
"cnrnews.settings.frequency_12h": "12 hours",
"cnrnews.settings.frequency_24h": "24 hours",
"artwork.settings.title": "Daily Artwork Settings",
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
"artwork.settings.source_label": "Mirror Source",
"artwork.settings.source_domestic": "Domestic Mirror",
"artwork.settings.source_overseas": "Overseas Mirror",
"artwork.settings.source_status_domestic": "Current source: Domestic mirror (optimized for China network)",
"artwork.settings.source_status_overseas": "Current source: Overseas mirror (art museum recommendations)",
"music.widget.unsupported": "Music control is not supported on this platform",
"music.widget.unsupported_hint": "This widget requires Windows SMTC",
"music.widget.no_session": "No music source",
@@ -395,3 +638,5 @@
"placement.center": "Center",
"placement.tile": "Tile"
}

View File

@@ -12,6 +12,9 @@
"settings.nav.status_bar": "状态栏",
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
"settings.nav.launcher": "应用启动台",
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
@@ -162,6 +165,21 @@
"schedule.settings.delete": "删除",
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
"worldclock.settings.title": "世界时钟设置",
"worldclock.settings.desc": "分别为四个时钟选择时区。",
"worldclock.settings.clock_1": "时钟 1",
"worldclock.settings.clock_2": "时钟 2",
"worldclock.settings.clock_3": "时钟 3",
"worldclock.settings.clock_4": "时钟 4",
"worldclock.settings.second_mode_label": "秒针方式",
"worldclock.widget.today": "今天",
"worldclock.widget.yesterday": "昨天",
"worldclock.widget.tomorrow": "明天",
"worldclock.widget.offset_same": "0 小时",
"worldclock.widget.offset_ahead_hours": "早 {0} 小时",
"worldclock.widget.offset_behind_hours": "晚 {0} 小时",
"worldclock.widget.offset_ahead_hm": "早 {0} 小时 {1} 分",
"worldclock.widget.offset_behind_hm": "晚 {0} 小时 {1} 分",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "更新于 {0:HH:mm}",
@@ -180,10 +198,45 @@
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
"settings.update.title": "更新",
"settings.update.current_version_label": "当前版本",
"settings.update.latest_version_label": "最新发布",
"settings.update.published_at_label": "发布时间",
"settings.update.options_header": "更新选项",
"settings.update.options_desc": "配置更新检查与发布通道。",
"settings.update.auto_check_toggle": "启动时自动检查更新",
"settings.update.include_prerelease_toggle": "包含预发布版本",
"settings.update.channel_label": "更新通道",
"settings.update.channel_stable": "正式版",
"settings.update.channel_preview": "预览版",
"settings.update.actions_header": "更新操作",
"settings.update.actions_desc": "检查发布、下载安装包并启动更新。",
"settings.update.check_button": "检查更新",
"settings.update.download_install_button": "下载并安装",
"settings.update.download_progress_idle": "下载进度:-",
"settings.update.download_progress_format": "下载进度:{0:F0}%",
"settings.update.status_ready": "可开始检查更新。",
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
"settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。",
"settings.update.status_checking": "正在检查 GitHub Release...",
"settings.update.status_check_failed_format": "检查更新失败:{0}",
"settings.update.status_up_to_date": "当前已是最新版本。",
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
"settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。",
"settings.update.status_downloading": "正在下载安装包...",
"settings.update.status_download_failed_format": "下载失败:{0}",
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",
"settings.about.codename_format": "版本代号: {0}",
"settings.about.font_format": "字体: {0}",
"settings.about.startup_header": "Windows 自启动",
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
"settings.about.startup_toggle": "登录 Windows 时启动",
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
@@ -192,14 +245,53 @@
"common.night": "夜间",
"common.back": "返回",
"common.close": "关闭",
"common.unknown": "未知错误",
"common.recommended": "推荐",
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
"launcher.empty_linux": "未找到 Linux .desktop 应用条目。",
"launcher.empty_folder": "此文件夹为空。",
"launcher.folder_items_format": "{0} 个应用",
"launcher.context.hide_icon": "隐藏图标",
"launcher.action.hide": "隐藏",
"settings.launcher.title": "应用启动台",
"settings.launcher.hidden_header": "已隐藏项目",
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
"settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.restore_button": "重新显示",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.installed_header": "已安装插件",
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
"settings.plugins.empty": "未找到插件。",
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
"settings.plugins.state.enabled": "已启用",
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
"settings.plugins.state.disabled": "已禁用",
"settings.plugins.state.loaded": "已加载",
"settings.plugins.state.load_failed": "加载失败",
"settings.plugins.toggle_on": "启用",
"settings.plugins.toggle_off": "禁用",
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
"settings.plugins.toggle_state_enabled": "启用",
"settings.plugins.toggle_state_disabled": "禁用",
"settings.plugins.source_package": ".laapp 包",
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
@@ -213,12 +305,14 @@
"component_category.board": "白板",
"component_category.media": "媒体",
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
"component.desktop_clock": "时钟",
"component.weather_clock": "天气时钟",
"component.world_clock": "世界时钟",
"component.desktop_timer": "计时器",
"component.desktop_weather": "天气",
"component.hourly_weather": "小时天气",
@@ -229,6 +323,14 @@
"component.audio_recorder": "录音",
"component.daily_poetry": "每日诗词",
"component.daily_artwork": "每日名画",
"component.daily_word": "每日单词",
"component.daily_word_2x2": "每日单词 2x2",
"component.cnr_daily_news": "央广网头条",
"component.ifeng_news": "凤凰网新闻",
"component.bilibili_hot_search": "B站热搜",
"component.baidu_hot_search": "百度热搜",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "汇率换算",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
@@ -241,6 +343,12 @@
"component.study_score_overview": "自习评分总览",
"component.study_deduction_reasons": "扣分原因",
"component.study_interrupt_density": "打断密度",
"desktop_clock.settings.title": "时钟设置",
"desktop_clock.settings.desc": "为单时钟选择时区。",
"desktop_clock.settings.timezone_label": "时区",
"desktop_clock.settings.second_mode_label": "秒针方式",
"clock.second_mode.tick": "跳针",
"clock.second_mode.sweep": "扫针",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",
@@ -255,6 +363,141 @@
"artwork.widget.fallback_artist": "推荐服务不可用",
"artwork.widget.fallback_year": "稍后重试",
"artwork.widget.unknown_artist": "未知作者",
"dailyword.widget.loading": "加载中...",
"dailyword.widget.loading_word": "每日单词",
"dailyword.widget.loading_pronunciation": "正在获取发音",
"dailyword.widget.loading_meaning": "正在获取释义",
"dailyword.widget.loading_example": "正在获取例句",
"dailyword.widget.loading_example_translation": "加载中",
"dailyword.widget.fetch_failed": "每日单词获取失败",
"dailyword.widget.fallback_word": "每日单词",
"dailyword.widget.fallback_pronunciation": "发音暂不可用",
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
"dailyword2x2.widget.tap_to_show": "点击查看释义",
"cnrnews.widget.loading": "加载中...",
"cnrnews.widget.loading_title": "正在获取新闻热点",
"cnrnews.widget.loading_subtitle": "请稍候",
"cnrnews.widget.fetch_failed": "新闻获取失败",
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
"cnrnews.widget.hot_label": "热点",
"bilihot.widget.brand": "bilibili 热搜",
"bilihot.widget.top_right_label": "bilibili热搜",
"bilihot.widget.search_entry": "搜索",
"bilihot.widget.search_placeholder": "搜索热词",
"bilihot.widget.loading": "加载中...",
"bilihot.widget.loading_item": "加载中...",
"bilihot.widget.fetch_failed": "热搜获取失败",
"bilihot.widget.fallback_item": "暂无热搜",
"bilihot.widget.more_hot": "更多热搜",
"baiduhot.widget.brand": "百度热搜",
"baiduhot.widget.loading": "加载中...",
"baiduhot.widget.loading_item": "加载中...",
"baiduhot.widget.fetch_failed": "热搜获取失败",
"baiduhot.widget.fallback_item": "暂无热搜",
"baiduhot.widget.refresh_tooltip": "刷新",
"ifeng.widget.brand": "凤凰网新闻",
"ifeng.widget.loading": "加载中...",
"ifeng.widget.loading_item": "加载中...",
"ifeng.widget.fetch_failed": "新闻获取失败",
"ifeng.widget.fallback_item": "暂无新闻",
"ifeng.widget.refresh_tooltip": "刷新",
"dailyword.settings.title": "每日单词设置",
"dailyword.settings.desc": "配置自动刷新开关与刷新频率。",
"dailyword.settings.auto_refresh_label": "自动刷新",
"dailyword.settings.auto_refresh_enabled": "启用自动刷新",
"dailyword.settings.frequency_label": "刷新频率",
"bilihot.settings.title": "B站热搜设置",
"bilihot.settings.desc": "配置自动刷新开关与刷新频率。",
"bilihot.settings.auto_refresh_label": "自动刷新",
"bilihot.settings.auto_refresh_enabled": "启用自动刷新",
"bilihot.settings.frequency_label": "刷新频率",
"baiduhot.settings.title": "百度热搜设置",
"baiduhot.settings.desc": "配置数据源、自动刷新开关与刷新频率。",
"baiduhot.settings.source_label": "数据源",
"baiduhot.settings.source_official": "百度官方源",
"baiduhot.settings.source_rss": "第三方 RSS 源",
"baiduhot.settings.auto_refresh_label": "自动刷新",
"baiduhot.settings.auto_refresh_enabled": "启用自动刷新",
"baiduhot.settings.frequency_label": "刷新频率",
"ifeng.settings.title": "凤凰网新闻设置",
"ifeng.settings.desc": "配置频道、自动刷新开关与刷新频率。",
"ifeng.settings.channel_label": "新闻频道",
"ifeng.settings.channel_comprehensive": "综合",
"ifeng.settings.channel_mainland": "中国大陆",
"ifeng.settings.channel_taiwan": "台湾",
"ifeng.settings.auto_refresh_label": "自动刷新",
"ifeng.settings.auto_refresh_enabled": "启用自动刷新",
"ifeng.settings.frequency_label": "刷新频率",
"refresh.frequency.5m": "5 分钟",
"refresh.frequency.10m": "10 分钟",
"refresh.frequency.12m": "12 分钟",
"refresh.frequency.15m": "15 分钟",
"refresh.frequency.20m": "20 分钟",
"refresh.frequency.30m": "30 分钟",
"refresh.frequency.40m": "40 分钟",
"refresh.frequency.1h": "1 小时",
"refresh.frequency.3h": "3 小时",
"refresh.frequency.6h": "6 小时",
"refresh.frequency.12h": "12 小时",
"refresh.frequency.24h": "24 小时",
"weather.widget.settings.title": "天气组件设置",
"weather.widget.settings.desc": "配置全部天气组件的自动刷新开关与刷新频率。",
"weather.widget.settings.auto_refresh_label": "自动刷新",
"weather.widget.settings.auto_refresh_enabled": "启用自动刷新",
"weather.widget.settings.frequency_label": "刷新频率",
"weather.widget.settings.frequency_10m": "10 分钟",
"weather.widget.settings.frequency_12m": "12 分钟",
"weather.widget.settings.frequency_15m": "15 分钟",
"weather.widget.settings.frequency_30m": "30 分钟",
"weather.widget.settings.frequency_1h": "1 小时",
"weather.widget.settings.frequency_3h": "3 小时",
"stcn24.widget.loading": "加载中...",
"stcn24.widget.loading_item": "加载中...",
"stcn24.widget.fetch_failed": "帖子获取失败",
"stcn24.widget.fallback_item": "暂无帖子",
"stcn24.settings.title": "STCN 24 设置",
"stcn24.settings.desc": "配置信息源、自动刷新开关与刷新频率。",
"stcn24.settings.source_label": "信息源",
"stcn24.settings.source_latest_created": "最新发布",
"stcn24.settings.source_latest_activity": "最新回复",
"stcn24.settings.source_most_replies": "回复最多",
"stcn24.settings.source_earliest_created": "最早发布",
"stcn24.settings.source_earliest_activity": "最早回复",
"stcn24.settings.source_least_replies": "回复最少",
"stcn24.settings.source_frontpage_latest": "前台推荐(新)",
"stcn24.settings.source_frontpage_earliest": "前台推荐(旧)",
"stcn24.settings.auto_refresh_label": "自动刷新",
"stcn24.settings.auto_refresh_enabled": "启用自动刷新",
"stcn24.settings.frequency_label": "刷新频率",
"stcn24.settings.frequency_5m": "5 分钟",
"stcn24.settings.frequency_10m": "10 分钟",
"stcn24.settings.frequency_20m": "20 分钟",
"stcn24.settings.frequency_30m": "30 分钟",
"stcn24.settings.frequency_1h": "1 小时",
"stcn24.settings.frequency_3h": "3 小时",
"exchange.widget.loading": "正在加载汇率...",
"exchange.widget.fetch_failed": "汇率获取失败",
"cnrnews.settings.title": "央广网设置",
"cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。",
"cnrnews.settings.auto_rotate_label": "自动轮换",
"cnrnews.settings.auto_rotate_enabled": "启用自动轮换",
"cnrnews.settings.frequency_label": "轮换频率",
"cnrnews.settings.frequency_5m": "5 分钟",
"cnrnews.settings.frequency_10m": "10 分钟",
"cnrnews.settings.frequency_40m": "40 分钟",
"cnrnews.settings.frequency_1h": "1 小时",
"cnrnews.settings.frequency_12h": "12 小时",
"cnrnews.settings.frequency_24h": "24 小时",
"artwork.settings.title": "每日图片设置",
"artwork.settings.desc": "切换每日图片的数据源。",
"artwork.settings.source_label": "镜像源",
"artwork.settings.source_domestic": "国内镜像",
"artwork.settings.source_overseas": "国外镜像",
"artwork.settings.source_status_domestic": "当前源:国内镜像(优先中国网络)",
"artwork.settings.source_status_overseas": "当前源:国外镜像(艺术馆推荐)",
"music.widget.unsupported": "当前平台不支持音乐控制",
"music.widget.unsupported_hint": "该组件仅支持 Windows SMTC",
"music.widget.no_session": "暂无音源",
@@ -395,3 +638,5 @@
"placement.center": "居中",
"placement.tile": "平铺"
}

View File

@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
public int SettingsTabIndex { get; set; } = 0;
public string? SettingsTabTag { get; set; }
public string LanguageCode { get; set; } = "zh-CN";
public string? TimeZoneId { get; set; }
@@ -44,6 +46,14 @@ public sealed class AppSettingsSnapshot
public bool WeatherNoTlsRequests { get; set; }
public bool AutoStartWithWindows { get; set; }
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
public string UpdateChannel { get; set; } = string.Empty;
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =
@@ -62,18 +72,22 @@ public sealed class AppSettingsSnapshot
public int StatusBarCustomSpacingPercent { get; set; } = 12;
public int DesktopPageCount { get; set; } = 1;
public List<string> DisabledPluginIds { get; set; } = [];
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
clone.TopStatusComponentIds = TopStatusComponentIds is { Count: > 0 }
? new List<string>(TopStatusComponentIds)
: [];
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
? new List<string>(PinnedTaskbarActions)
: [];
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds)
: [];
return clone;
}
}

View File

@@ -0,0 +1,19 @@
using System;
namespace LanMountainDesktop.Models;
public static class BaiduHotSearchSourceTypes
{
public const string Official = "Official";
public const string ThirdPartyRss = "ThirdPartyRss";
public static string Normalize(string? sourceType)
{
if (string.Equals(sourceType, ThirdPartyRss, StringComparison.OrdinalIgnoreCase))
{
return ThirdPartyRss;
}
return Official;
}
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class ComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string> WorldClockTimeZoneIds { get; set; } =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
if (ImportedClassSchedules is not null)
{
foreach (var schedule in ImportedClassSchedules)
{
if (schedule is null)
{
continue;
}
schedules.Add(new ImportedClassScheduleSnapshot
{
Id = schedule.Id,
DisplayName = schedule.DisplayName,
FilePath = schedule.FilePath
});
}
}
clone.ImportedClassSchedules = schedules;
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
: [];
return clone;
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace LanMountainDesktop.Models;
public static class DailyArtworkMirrorSources
{
public const string Domestic = "Domestic";
public const string Overseas = "Overseas";
public static string Normalize(string? value)
{
return string.Equals(value, Domestic, StringComparison.OrdinalIgnoreCase)
? Domestic
: Overseas;
}
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class DesktopLayoutSettingsSnapshot
{
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; }
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
public DesktopLayoutSettingsSnapshot Clone()
{
var clone = (DesktopLayoutSettingsSnapshot)MemberwiseClone();
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
if (DesktopComponentPlacements is not null)
{
foreach (var placement in DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId,
PageIndex = placement.PageIndex,
ComponentId = placement.ComponentId,
Row = placement.Row,
Column = placement.Column,
WidthCells = placement.WidthCells,
HeightCells = placement.HeightCells
});
}
}
clone.DesktopComponentPlacements = placements;
return clone;
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public static class IfengNewsChannelTypes
{
public const string Comprehensive = "Comprehensive";
public const string Mainland = "Mainland";
public const string Taiwan = "Taiwan";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Comprehensive,
Mainland,
Taiwan
];
public static string Normalize(string? value)
{
var candidate = value?.Trim() ?? string.Empty;
foreach (var supported in SupportedValues)
{
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
{
return supported;
}
}
return Comprehensive;
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class LauncherSettingsSnapshot
{
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public LauncherSettingsSnapshot Clone()
{
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
? new List<string>(HiddenLauncherFolderPaths)
: [];
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
? new List<string>(HiddenLauncherAppPaths)
: [];
return clone;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
@@ -10,6 +11,7 @@ public sealed record DailyArtworkSnapshot(
string? Museum,
string? ArtworkUrl,
string? ImageUrl,
string? ThumbnailDataUrl,
DateTimeOffset FetchedAt);
public sealed record DailyPoetrySnapshot(
@@ -19,3 +21,77 @@ public sealed record DailyPoetrySnapshot(
string? Author,
string? Category,
DateTimeOffset FetchedAt);
public sealed record DailyNewsItemSnapshot(
string Title,
string? Summary,
string Url,
string? ImageUrl,
string? PublishTime);
public sealed record DailyNewsSnapshot(
string Provider,
string Source,
IReadOnlyList<DailyNewsItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record BilibiliHotSearchItemSnapshot(
string Title,
string Keyword,
string Url,
long? HeatScore,
bool HasHotTag,
string? IconUrl);
public sealed record BilibiliHotSearchSnapshot(
string Provider,
string Source,
string SearchPlaceholder,
string SearchUrl,
string MoreHotUrl,
IReadOnlyList<BilibiliHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record BaiduHotSearchItemSnapshot(
string Title,
string Url,
long? HeatScore);
public sealed record BaiduHotSearchSnapshot(
string Provider,
string Source,
string BoardUrl,
IReadOnlyList<BaiduHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record DailyWordSnapshot(
string Provider,
string Word,
string? UkPronunciation,
string? UsPronunciation,
string Meaning,
string? ExampleSentence,
string? ExampleTranslation,
string? SourceUrl,
DateTimeOffset FetchedAt);
public sealed record ExchangeRateSnapshot(
string Provider,
string Source,
string BaseCurrency,
string TargetCurrency,
decimal Rate,
DateTimeOffset FetchedAt);
public sealed record Stcn24ForumPostItemSnapshot(
string Title,
string Url,
string? AuthorDisplayName,
string? AuthorAvatarUrl,
DateTimeOffset? CreatedAt);
public sealed record Stcn24ForumPostsSnapshot(
string Provider,
string Source,
IReadOnlyList<Stcn24ForumPostItemSnapshot> Items,
DateTimeOffset FetchedAt);

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Models;
public static class RefreshIntervalCatalog
{
public static IReadOnlyList<int> SupportedIntervalsMinutes { get; } =
[
5,
10,
12,
15,
20,
30,
40,
60,
180,
360,
720,
1440
];
public static int Normalize(int minutes, int fallbackMinutes)
{
if (minutes <= 0)
{
return fallbackMinutes;
}
if (SupportedIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(fallbackMinutes);
}
public static string ToLocalizationKeySuffix(int minutes)
{
return minutes switch
{
5 => "5m",
10 => "10m",
12 => "12m",
15 => "15m",
20 => "20m",
30 => "30m",
40 => "40m",
60 => "1h",
180 => "3h",
360 => "6h",
720 => "12h",
1440 => "24h",
_ => $"{minutes}m"
};
}
public static string ToEnglishFallbackLabel(int minutes)
{
return minutes switch
{
60 => "1 hour",
180 => "3 hours",
360 => "6 hours",
720 => "12 hours",
1440 => "24 hours",
_ => $"{minutes} min"
};
}
}

View File

@@ -1,4 +1,6 @@
namespace LanMountainDesktop.Models;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class StartMenuAppEntry
{
@@ -9,4 +11,10 @@ public sealed class StartMenuAppEntry
public required string RelativePath { get; init; }
public byte[]? IconPngBytes { get; init; }
public string? LaunchExecutable { get; init; }
public IReadOnlyList<string> LaunchArguments { get; init; } = [];
public string? WorkingDirectory { get; init; }
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public static class Stcn24ForumSourceTypes
{
public const string LatestCreated = "LatestCreated";
public const string LatestActivity = "LatestActivity";
public const string MostReplies = "MostReplies";
public const string EarliestCreated = "EarliestCreated";
public const string EarliestActivity = "EarliestActivity";
public const string LeastReplies = "LeastReplies";
public const string FrontpageLatest = "FrontpageLatest";
public const string FrontpageEarliest = "FrontpageEarliest";
public static IReadOnlyList<string> SupportedValues { get; } =
[
LatestCreated,
LatestActivity,
MostReplies,
EarliestCreated,
EarliestActivity,
LeastReplies,
FrontpageLatest,
FrontpageEarliest
];
public static string Normalize(string? value)
{
var candidate = value?.Trim() ?? string.Empty;
foreach (var supported in SupportedValues)
{
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
{
return supported;
}
}
return LatestCreated;
}
}

View File

@@ -7,5 +7,6 @@ public enum TaskbarActionId
AddDesktopPage,
DeleteDesktopPage,
DeleteComponent,
EditComponent
EditComponent,
HideLauncherEntry
}

View File

@@ -7,13 +7,24 @@ namespace LanMountainDesktop.Services;
public sealed class AppSettingsService
{
public static event Action<string>? SettingsSaved;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static AppSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
public AppSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -25,14 +36,32 @@ public sealed class AppSettingsService
{
try
{
if (!File.Exists(_settingsPath))
lock (CacheGate)
{
return new AppSettingsSnapshot();
}
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions);
return snapshot ?? new AppSettingsSnapshot();
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
var loadedSnapshot = hasFile
? LoadSnapshotFromDisk()
: new AppSettingsSnapshot();
UpdateCache(loadedSnapshot, writeTimeUtc, nowUtc);
return loadedSnapshot.Clone();
}
}
catch
{
@@ -42,6 +71,8 @@ public sealed class AppSettingsService
public void Save(AppSettingsSnapshot snapshot)
{
var snapshotToPersist = snapshot?.Clone() ?? new AppSettingsSnapshot();
try
{
var directory = Path.GetDirectoryName(_settingsPath);
@@ -50,13 +81,72 @@ public sealed class AppSettingsService
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
var json = JsonSerializer.Serialize(snapshotToPersist, SerializerOptions);
File.WriteAllText(_settingsPath, json);
var writeTimeUtc = File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
SettingsSaved?.Invoke(InstanceId);
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out AppSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out AppSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private AppSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
}
catch
{
return new AppSettingsSnapshot();
}
}
private void UpdateCache(AppSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Globalization;
namespace LanMountainDesktop.Services;
public sealed class CalculatorDataService : ICalculatorDataService
{
private const int MaxInputLength = 18;
public string ApplyInputToken(string currentInput, string token)
{
var normalized = NormalizeInput(currentInput);
if (string.IsNullOrWhiteSpace(token))
{
return normalized;
}
if (string.Equals(token, CalculatorInputTokens.Clear, StringComparison.OrdinalIgnoreCase))
{
return "0";
}
if (string.Equals(token, CalculatorInputTokens.Backspace, StringComparison.OrdinalIgnoreCase))
{
if (normalized.Length <= 1)
{
return "0";
}
var trimmed = normalized[..^1];
if (trimmed is "-" or "" or "-0")
{
return "0";
}
return trimmed;
}
if (string.Equals(token, CalculatorInputTokens.DecimalPoint, StringComparison.Ordinal))
{
if (normalized.Contains('.', StringComparison.Ordinal))
{
return normalized;
}
if (normalized.Length >= MaxInputLength)
{
return normalized;
}
return $"{normalized}.";
}
if (token is "00")
{
if (normalized == "0")
{
return "0";
}
if (normalized.Length + 2 > MaxInputLength)
{
return normalized;
}
return normalized + "00";
}
if (token.Length == 1 && char.IsDigit(token[0]))
{
if (normalized == "0")
{
return token;
}
if (normalized.Length >= MaxInputLength)
{
return normalized;
}
return normalized + token;
}
return normalized;
}
public decimal ParseAmountOrZero(string? inputText)
{
var normalized = NormalizeInput(inputText);
if (decimal.TryParse(
normalized,
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
CultureInfo.InvariantCulture,
out var amount))
{
return amount;
}
return 0m;
}
public string FormatAmount(decimal amount, int maxFractionDigits = 4)
{
var safeDigits = Math.Clamp(maxFractionDigits, 0, 8);
var pattern = safeDigits == 0 ? "0" : $"0.{new string('#', safeDigits)}";
return amount.ToString(pattern, CultureInfo.InvariantCulture);
}
private static string NormalizeInput(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return "0";
}
var trimmed = input.Trim();
return trimmed switch
{
"-" or "-0" => "0",
_ => trimmed
};
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -39,7 +39,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
};
private static readonly IDeserializer CsesDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
{
if (string.IsNullOrWhiteSpace(inputPath))
{
inputPath = ResolveImportedSchedulePathFromAppSettings();
inputPath = ResolveImportedSchedulePathFromComponentSettings();
}
var source = ResolveSource(inputPath, profileFileName, warnings);
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return result;
}
private static string? ResolveImportedSchedulePathFromAppSettings()
private static string? ResolveImportedSchedulePathFromComponentSettings()
{
try
{
var snapshot = new AppSettingsService().Load();
var snapshot = new ComponentSettingsService().Load();
if (snapshot.ImportedClassSchedules.Count == 0)
{
return null;

View File

@@ -0,0 +1,21 @@
using System;
namespace LanMountainDesktop.Services;
public static class ClockSecondHandMode
{
public const string Tick = "Tick";
public const string Sweep = "Sweep";
public static string Normalize(string? mode)
{
return string.Equals(mode?.Trim(), Sweep, StringComparison.OrdinalIgnoreCase)
? Sweep
: Tick;
}
public static bool IsSweep(string? mode)
{
return string.Equals(Normalize(mode), Sweep, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,751 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
private string _scopedComponentId = string.Empty;
private string _scopedPlacementId = string.Empty;
public ComponentSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public ComponentSettingsSnapshot Load()
{
if (HasScopedComponentContext())
{
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
return document.DefaultSettings.Clone();
}
}
catch
{
return new ComponentSettingsSnapshot();
}
}
public void Save(ComponentSettingsSnapshot snapshot)
{
if (HasScopedComponentContext())
{
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
return;
}
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.DefaultSettings = snapshotToPersist;
PersistDocumentLocked(document);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
{
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (!string.IsNullOrWhiteSpace(instanceKey) &&
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
{
return snapshot.Clone();
}
return document.DefaultSettings.Clone();
}
}
catch
{
return new ComponentSettingsSnapshot();
}
}
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var normalizedSnapshot = NormalizeSnapshot(snapshot);
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
Save(normalizedSnapshot);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.InstanceSettings[instanceKey] = normalizedSnapshot;
PersistDocumentLocked(document);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
public void DeleteForComponent(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var changed = document.InstanceSettings.Remove(instanceKey);
changed |= document.PluginSettings.Remove(instanceKey);
if (changed)
{
PersistDocumentLocked(document);
}
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
{
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey) ||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
{
return new T();
}
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
}
}
catch
{
return new T();
}
}
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
PersistDocumentLocked(document);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
public void DeletePluginSettings(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
if (document.PluginSettings.Remove(instanceKey))
{
PersistDocumentLocked(document);
}
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
public void SetScopedComponentContext(string componentId, string? placementId)
{
_scopedComponentId = componentId?.Trim() ?? string.Empty;
_scopedPlacementId = placementId?.Trim() ?? string.Empty;
}
public void ClearScopedComponentContext()
{
_scopedComponentId = string.Empty;
_scopedPlacementId = string.Empty;
}
public static void ApplyScopedContextToTarget(object? target, string componentId, string? placementId)
{
if (target is null)
{
return;
}
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (var field in target.GetType().GetFields(flags))
{
if (field.FieldType != typeof(ComponentSettingsService))
{
continue;
}
if (field.GetValue(target) is ComponentSettingsService settingsService)
{
settingsService.SetScopedComponentContext(componentId, placementId);
}
}
foreach (var property in target.GetType().GetProperties(flags))
{
if (property.PropertyType != typeof(ComponentSettingsService) ||
!property.CanRead)
{
continue;
}
if (property.GetValue(target) is ComponentSettingsService settingsService)
{
settingsService.SetScopedComponentContext(componentId, placementId);
}
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
ComponentSettingsDocumentSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
};
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
}
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object &&
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
document.RootElement.TryGetProperty("pluginSettings", out _)))
{
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
return NormalizeDocument(snapshot);
}
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(legacySnapshot)
};
}
catch
{
return new ComponentSettingsDocumentSnapshot();
}
}
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
{
snapshot = new ComponentSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new ComponentSettingsSnapshot
{
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
IfengNewsChannelType = legacy.IfengNewsChannelType,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
};
return true;
}
catch
{
return false;
}
}
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
{
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
}
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
normalized.ActiveImportedClassScheduleId,
normalized.ImportedClassSchedules);
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
{
normalized.StudyEnvironmentShowDisplayDb = true;
}
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
.ToList();
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
return normalized;
}
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.InstanceSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
instanceSettings[key] = NormalizeSnapshot(pair.Value);
}
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.PluginSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
pluginSettings[key] = pair.Value.Clone();
}
normalized.InstanceSettings = instanceSettings;
normalized.PluginSettings = pluginSettings;
return normalized;
}
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
{
if (schedules is null || schedules.Count == 0)
{
return [];
}
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
var id = schedule.Id?.Trim() ?? string.Empty;
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
{
continue;
}
if (!seenIds.Add(id))
{
continue;
}
result.Add(new ImportedClassScheduleSnapshot
{
Id = id,
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
FilePath = filePath
});
}
return result;
}
private static string NormalizeActiveScheduleId(
string? activeScheduleId,
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
{
var activeId = activeScheduleId?.Trim() ?? string.Empty;
if (schedules.Count == 0)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(activeId))
{
return schedules[0].Id;
}
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
? activeId
: schedules[0].Id;
}
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
{
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
? "China Standard Time"
: timeZoneId.Trim();
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
}
private static int NormalizeCnrInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 60);
}
private static int NormalizeDailyWordInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 360);
}
private static int NormalizeIfengNewsInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeBaiduHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeWeatherInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 12);
}
private static int NormalizeStcn24ForumInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return string.Empty;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeInstanceKey(string? key)
{
return key?.Trim() ?? string.Empty;
}
private bool HasScopedComponentContext()
{
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
!string.IsNullOrWhiteSpace(_scopedPlacementId);
}
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class ComponentSettingsDocumentSnapshot
{
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public ComponentSettingsDocumentSnapshot Clone()
{
var clone = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
};
foreach (var pair in InstanceSettings)
{
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
}
foreach (var pair in PluginSettings)
{
clone.PluginSettings[pair.Key] = pair.Value.Clone();
}
return clone;
}
}
private sealed class LegacyComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
public string? ActiveImportedClassScheduleId { get; set; }
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string>? WorldClockTimeZoneIds { get; set; }
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
}
}

View File

@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ComponentSystem.Extensions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
public static class DesktopComponentRegistryFactory
{
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
{
var registry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
return pluginDefinitions.Count == 0
? registry
: registry.RegisterComponents(pluginDefinitions);
}
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService)
{
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
var registeredIds = new HashSet<string>(
registrations.Select(registration => registration.ComponentId),
StringComparer.OrdinalIgnoreCase);
if (pluginRuntimeService is not null)
{
foreach (var contribution in pluginRuntimeService.DesktopComponents)
{
var registration = contribution.Registration;
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
{
continue;
}
if (!registeredIds.Add(registration.ComponentId))
{
Debug.WriteLine(
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
continue;
}
registrations.Add(new DesktopComponentRuntimeRegistration(
registration.ComponentId,
registration.DisplayNameLocalizationKey,
factoryContext => CreatePluginControl(contribution, factoryContext),
registration.CornerRadiusResolver));
}
}
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
}
private static List<DesktopComponentDefinition> GetPluginDefinitions(
ComponentRegistry baseRegistry,
PluginRuntimeService? pluginRuntimeService)
{
var definitions = new List<DesktopComponentDefinition>();
if (pluginRuntimeService is null)
{
return definitions;
}
var knownIds = new HashSet<string>(
baseRegistry.GetAll().Select(definition => definition.Id),
StringComparer.OrdinalIgnoreCase);
foreach (var contribution in pluginRuntimeService.DesktopComponents)
{
var registration = contribution.Registration;
if (!knownIds.Add(registration.ComponentId))
{
Debug.WriteLine(
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
continue;
}
definitions.Add(new DesktopComponentDefinition(
registration.ComponentId,
registration.DisplayName,
registration.IconKey,
registration.Category,
registration.MinWidthCells,
registration.MinHeightCells,
registration.AllowStatusBarPlacement,
registration.AllowDesktopPlacement,
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
? DesktopComponentResizeMode.Free
: DesktopComponentResizeMode.Proportional));
}
return definitions;
}
private static Control CreatePluginControl(
PluginDesktopComponentContribution contribution,
DesktopComponentControlFactoryContext context)
{
try
{
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
contribution.Plugin.Context.DataDirectory,
contribution.Plugin.Context.Services,
contribution.Plugin.Context.Properties,
contribution.Registration.ComponentId,
context.PlacementId,
context.CellSize);
return contribution.Registration.ControlFactory(pluginContext);
}
catch (Exception ex)
{
Debug.WriteLine(
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
return CreatePluginErrorControl(contribution, ex);
}
}
private static Control CreatePluginErrorControl(
PluginDesktopComponentContribution contribution,
Exception exception)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(12),
Child = new StackPanel
{
Spacing = 6,
Children =
{
new TextBlock
{
Text = contribution.Registration.DisplayName,
FontSize = 14,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = exception.Message,
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
}

View File

@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class DesktopLayoutSettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public DesktopLayoutSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public DesktopLayoutSettingsSnapshot Load()
{
try
{
lock (CacheGate)
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
DesktopLayoutSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch
{
return new DesktopLayoutSettingsSnapshot();
}
}
public void Save(DesktopLayoutSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch
{
return new DesktopLayoutSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
{
snapshot = new DesktopLayoutSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new DesktopLayoutSettingsSnapshot
{
DesktopPageCount = legacy.DesktopPageCount,
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
};
return true;
}
catch
{
return false;
}
}
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
if (normalized.DesktopComponentPlacements is not null)
{
foreach (var placement in normalized.DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
PageIndex = Math.Max(0, placement.PageIndex),
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
Row = Math.Max(0, placement.Row),
Column = Math.Max(0, placement.Column),
WidthCells = Math.Max(1, placement.WidthCells),
HeightCells = Math.Max(1, placement.HeightCells)
});
}
}
normalized.DesktopComponentPlacements = placements;
return normalized;
}
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyDesktopLayoutSettingsSnapshot
{
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; }
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
}
}

View File

@@ -0,0 +1,482 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed record GitHubReleaseAsset(
string Name,
string BrowserDownloadUrl,
long SizeBytes);
public sealed record GitHubReleaseInfo(
string TagName,
string Name,
bool IsPrerelease,
bool IsDraft,
DateTimeOffset PublishedAt,
IReadOnlyList<GitHubReleaseAsset> Assets);
public sealed record UpdateCheckResult(
bool Success,
bool IsUpdateAvailable,
string CurrentVersionText,
string LatestVersionText,
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage);
public sealed record UpdateDownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage);
public sealed class GitHubReleaseUpdateService : IDisposable
{
private const string GithubApiVersion = "2022-11-28";
private readonly string _owner;
private readonly string _repo;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public GitHubReleaseUpdateService(
string owner,
string repo,
HttpClient? httpClient = null)
{
_owner = owner?.Trim() ?? string.Empty;
_repo = repo?.Trim() ?? string.Empty;
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
}
if (!_httpClient.DefaultRequestHeaders.Accept.Any())
{
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
}
if (!_httpClient.DefaultRequestHeaders.Contains("X-GitHub-Api-Version"))
{
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", GithubApiVersion);
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "Repository information is not configured.");
}
try
{
var release = includePrerelease
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
: await GetLatestStableReleaseAsync(cancellationToken);
if (release is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "No release data was returned from GitHub.");
}
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? parsedTagVersion.ToString(3)
: release.TagName;
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets)
: null;
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: isUpdateAvailable,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: ex.Message);
}
}
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (asset is null)
{
return new UpdateDownloadResult(false, null, "Asset is null.");
}
if (string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl))
{
return new UpdateDownloadResult(false, null, "Asset download url is empty.");
}
if (string.IsNullOrWhiteSpace(destinationFilePath))
{
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
}
try
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
using var response = await _httpClient.GetAsync(
asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new UpdateDownloadResult(
false,
null,
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
var contentLength = response.Content.Headers.ContentLength ??
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(destinationFilePath);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
totalRead += read;
if (contentLength > 0)
{
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
}
}
progress?.Report(1d);
return new UpdateDownloadResult(true, destinationFilePath, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, ex.Message);
}
}
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
return ParseRelease(document.RootElement);
}
private async Task<GitHubReleaseInfo?> GetLatestReleaseIncludingPrereleaseAsync(CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases?per_page=20";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var item in document.RootElement.EnumerateArray())
{
var release = ParseRelease(item);
if (release is null || release.IsDraft)
{
continue;
}
return release;
}
return null;
}
private async Task<string> GetResponseTextAsync(string url, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(url, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GitHub API request failed with HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
return responseText;
}
private static GitHubReleaseInfo? ParseRelease(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
var tagName = element.TryGetProperty("tag_name", out var tagNode)
? tagNode.GetString()?.Trim()
: null;
if (string.IsNullOrWhiteSpace(tagName))
{
return null;
}
var name = element.TryGetProperty("name", out var nameNode)
? nameNode.GetString()?.Trim() ?? string.Empty
: string.Empty;
var isPrerelease = element.TryGetProperty("prerelease", out var prereleaseNode) &&
prereleaseNode.ValueKind == JsonValueKind.True;
var isDraft = element.TryGetProperty("draft", out var draftNode) &&
draftNode.ValueKind == JsonValueKind.True;
var publishedAt = DateTimeOffset.MinValue;
if (element.TryGetProperty("published_at", out var publishedAtNode) &&
publishedAtNode.ValueKind == JsonValueKind.String)
{
var publishedAtText = publishedAtNode.GetString();
if (!string.IsNullOrWhiteSpace(publishedAtText) &&
DateTimeOffset.TryParse(
publishedAtText,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var parsedPublishedAt))
{
publishedAt = parsedPublishedAt;
}
}
var assets = new List<GitHubReleaseAsset>();
if (element.TryGetProperty("assets", out var assetsNode) && assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
{
if (assetNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var assetName = assetNode.TryGetProperty("name", out var assetNameNode)
? assetNameNode.GetString()?.Trim()
: null;
var browserDownloadUrl = assetNode.TryGetProperty("browser_download_url", out var urlNode)
? urlNode.GetString()?.Trim()
: null;
var sizeBytes = assetNode.TryGetProperty("size", out var sizeNode) && sizeNode.TryGetInt64(out var size)
? size
: 0L;
if (string.IsNullOrWhiteSpace(assetName) || string.IsNullOrWhiteSpace(browserDownloadUrl))
{
continue;
}
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
}
}
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
{
return null;
}
var architectureToken = RuntimeInformation.OSArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64"
};
var ranked = assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.ToList();
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
}
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
score += 200;
}
else if (assetName.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (assetName.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("installer", StringComparison.OrdinalIgnoreCase))
{
score += 60;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
if (assetName.Contains("portable", StringComparison.OrdinalIgnoreCase))
{
score -= 40;
}
return score;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim();
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = NormalizeVersion(parsed);
return true;
}
private static Version NormalizeVersion(Version version)
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build);
return new Version(major, minor, build);
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Services;
public interface ICalculatorDataService
{
string ApplyInputToken(string currentInput, string token);
decimal ParseAmountOrZero(string? inputText);
string FormatAmount(decimal amount, int maxFractionDigits = 4);
}
public static class CalculatorInputTokens
{
public const string Clear = "AC";
public const string Backspace = "BACK";
public const string DecimalPoint = ".";
}

View File

@@ -0,0 +1,22 @@
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public interface IComponentInstanceSettingsStore
{
ComponentSettingsSnapshot Load();
void Save(ComponentSettingsSnapshot snapshot);
ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId);
void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
void DeleteForComponent(string componentId, string? placementId);
T LoadPluginSettings<T>(string componentId, string? placementId) where T : new();
void SavePluginSettings<T>(string componentId, string? placementId, T settings);
void DeletePluginSettings(string componentId, string? placementId);
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
@@ -7,12 +8,50 @@ namespace LanMountainDesktop.Services;
public sealed record DailyArtworkQuery(
string? Locale = null,
string? MirrorSource = null,
bool ForceRefresh = false);
public sealed record DailyPoetryQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyNewsQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record IfengNewsQuery(
string? Locale = null,
int? ItemCount = null,
string? ChannelType = null,
bool ForceRefresh = false);
public sealed record BilibiliHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record BaiduHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
string? SourceType = null,
bool ForceRefresh = false);
public sealed record DailyWordQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record Stcn24ForumPostsQuery(
string? Locale = null,
int? ItemCount = null,
string? SourceType = null,
bool ForceRefresh = false);
public sealed record ExchangeRateQuery(
string? BaseCurrency = null,
string? TargetCurrency = null,
bool ForceRefresh = false);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -35,16 +74,217 @@ public sealed record RecommendationApiOptions
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
public string ArtInstituteArtworkApiTemplate { get; init; } =
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link,thumbnail";
public string ArtInstituteImageUrlTemplate { get; init; } =
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
public string DomesticArtworkApiUrl { get; init; } =
"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=8&mkt=zh-CN";
public string DomesticArtworkHost { get; init; } = "https://cn.bing.com";
public string CnrDailyNewsListUrl { get; init; } = "https://www.cnr.cn/newscenter/native/gd/";
public IReadOnlyList<string> CnrDailyNewsRssFeedUrls { get; init; } =
[
"https://www.cnr.cn/rss.xml",
"https://news.cnr.cn/rss.xml",
"https://www.cnr.cn/newscenter/native/gd/rss.xml",
"https://news.cnr.cn/native/gd/rss.xml"
];
public IReadOnlyList<string> IfengNewsComprehensiveRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news",
"https://rsshub.shuaizheng.org/ifeng/news"
];
public IReadOnlyList<string> IfengNewsMainlandRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/"
];
public IReadOnlyList<string> IfengNewsTaiwanRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35199-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35199-/"
];
public string IfengNewsComprehensiveListPageUrl { get; init; } = "https://news.ifeng.com/";
public string IfengNewsMainlandListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35197-/";
public string IfengNewsTaiwanListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35199-/";
public string BilibiliHotSearchApiTemplate { get; init; } =
"https://api.bilibili.com/x/web-interface/search/square?limit={0}";
public string BilibiliSearchDefaultApiUrl { get; init; } =
"https://api.bilibili.com/x/web-interface/search/default";
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
public string BaiduHotSearchRssFeedUrl { get; init; } = "https://rss.aishort.top/?type=baidu";
public string BaiduHotSearchBoardUrl { get; init; } = "https://top.baidu.com/board?tab=realtime";
public string SmartTeachForumApiTemplate { get; init; } =
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
public string SmartTeachStcnKeyword { get; init; } = "STCN";
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
public string ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{0}";
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
[
"illustrate",
"resilient",
"meticulous",
"coherent",
"subtle",
"constrain",
"tangible",
"versatile",
"pragmatic",
"derive",
"intricate",
"notion",
"facilitate",
"sustain",
"clarify",
"convey",
"nuance",
"transform",
"navigate",
"align",
"elevate",
"refine",
"vivid",
"compile",
"inspect",
"aggregate",
"optimize",
"resonate",
"persist",
"adapt",
"emerge",
"concrete",
"articulate",
"validate",
"insight",
"concise",
"robust",
"reliable",
"spectrum",
"landscape",
"context",
"constraint",
"iterative",
"foundation",
"priority",
"workflow",
"synthesize",
"anchor",
"precision",
"momentum",
"integrate",
"observe",
"structure",
"essence",
"framework",
"drift",
"discern",
"compose",
"modulate",
"stability",
"trajectory",
"analyze",
"diagnose",
"mitigate",
"transparent",
"progressive",
"boundary",
"allocate",
"evaluate",
"reconcile",
"strategic",
"holistic",
"incremental",
"temporal",
"semantic",
"parallel",
"explicit",
"objective",
"capacity",
"durable",
"scalable",
"residual",
"verify",
"discover",
"curate",
"invoke",
"artistry",
"sincere",
"substantive",
"deliberate",
"dynamic",
"intentional",
"initiative",
"evidence",
"infuse",
"harmony",
"vitality",
"polish",
"portrait",
"rhythm",
"accent",
"gradient",
"palette",
"pattern",
"eclipse",
"horizon",
"luminous",
"serene",
"vantage",
"kinetic",
"refactor",
"calibrate",
"orchestrate",
"prototype",
"curiosity",
"discipline",
"inscribe",
"engage",
"spark",
"zenith",
"clarity",
"resolve",
"aptitude"
];
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
public int DefaultArtworkCandidateCount { get; init; } = 50;
public int DefaultDailyNewsCount { get; init; } = 2;
public int DefaultIfengNewsCount { get; init; } = 4;
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
public int DefaultBaiduHotSearchCount { get; init; } = 4;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
}
public interface IRecommendationInfoService
@@ -57,5 +297,33 @@ public interface IRecommendationInfoService
DailyPoetryQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetDailyNewsAsync(
DailyNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetIfengNewsAsync(
IfengNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
BilibiliHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> GetBaiduHotSearchAsync(
BaiduHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
DailyWordQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
Stcn24ForumPostsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
ExchangeRateQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}

View File

@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class LauncherSettingsService
{
public static event Action<string>? SettingsSaved;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static LauncherSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
public LauncherSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public LauncherSettingsSnapshot Load()
{
try
{
lock (CacheGate)
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
LauncherSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new LauncherSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch
{
return new LauncherSettingsSnapshot();
}
}
public void Save(LauncherSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
SettingsSaved?.Invoke(InstanceId);
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out LauncherSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out LauncherSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private LauncherSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch
{
return new LauncherSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out LauncherSettingsSnapshot snapshot)
{
snapshot = new LauncherSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyLauncherSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new LauncherSettingsSnapshot
{
HiddenLauncherFolderPaths = legacy.HiddenLauncherFolderPaths ?? [],
HiddenLauncherAppPaths = legacy.HiddenLauncherAppPaths ?? []
};
return true;
}
catch
{
return false;
}
}
private DateTime PersistSnapshotToDisk(LauncherSettingsSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static LauncherSettingsSnapshot NormalizeSnapshot(LauncherSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new LauncherSettingsSnapshot();
normalized.HiddenLauncherFolderPaths = NormalizeKeys(normalized.HiddenLauncherFolderPaths);
normalized.HiddenLauncherAppPaths = NormalizeKeys(normalized.HiddenLauncherAppPaths);
return normalized;
}
private static List<string> NormalizeKeys(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return [];
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void UpdateCache(LauncherSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyLauncherSettingsSnapshot
{
public List<string>? HiddenLauncherFolderPaths { get; set; }
public List<string>? HiddenLauncherAppPaths { get; set; }
}
}

View File

@@ -0,0 +1,192 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace LanMountainDesktop.Services;
internal static class LinuxDesktopEntryInstaller
{
private const string DesktopFileName = "LanMountainDesktop.desktop";
private const string IconFileName = "lanmountaindesktop.png";
private const string IconName = "lanmountaindesktop";
public static void EnsureInstalled()
{
if (!OperatingSystem.IsLinux())
{
return;
}
try
{
var executablePath = ResolveExecutablePath();
if (string.IsNullOrWhiteSpace(executablePath))
{
return;
}
var dataHome = ResolveDataHome();
if (string.IsNullOrWhiteSpace(dataHome))
{
return;
}
var applicationsDir = Path.Combine(dataHome, "applications");
var iconDir = Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps");
Directory.CreateDirectory(applicationsDir);
Directory.CreateDirectory(iconDir);
var desktopTargetPath = Path.Combine(applicationsDir, DesktopFileName);
var iconTargetPath = Path.Combine(iconDir, IconFileName);
TryCopyBundledIcon(iconTargetPath);
var desktopEntryContent = BuildDesktopEntryContent(executablePath);
WriteFileIfChanged(desktopTargetPath, desktopEntryContent);
TryRunCommand("chmod", "+x", executablePath);
TryRunCommand("chmod", "+x", desktopTargetPath);
TryRunCommand("update-desktop-database", applicationsDir);
TryRunCommand("gtk-update-icon-cache", Path.Combine(dataHome, "icons", "hicolor"));
}
catch
{
// Keep startup resilient if desktop integration fails.
}
}
private static string ResolveExecutablePath()
{
var processPath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(processPath))
{
return processPath;
}
var commandLineArgs = Environment.GetCommandLineArgs();
if (commandLineArgs.Length > 0 && !string.IsNullOrWhiteSpace(commandLineArgs[0]))
{
return commandLineArgs[0];
}
return string.Empty;
}
private static string ResolveDataHome()
{
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (!string.IsNullOrWhiteSpace(dataHome))
{
return dataHome.Trim();
}
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homePath))
{
return string.Empty;
}
return Path.Combine(homePath, ".local", "share");
}
private static void TryCopyBundledIcon(string iconTargetPath)
{
foreach (var candidatePath in EnumerateIconSourceCandidates())
{
try
{
if (!File.Exists(candidatePath))
{
continue;
}
File.Copy(candidatePath, iconTargetPath, overwrite: true);
return;
}
catch
{
// Ignore failures and continue trying fallbacks.
}
}
}
private static string[] EnumerateIconSourceCandidates()
{
var baseDirectory = AppContext.BaseDirectory;
return
[
Path.Combine(baseDirectory, "share", "icons", "hicolor", "256x256", "apps", IconFileName),
Path.Combine(baseDirectory, IconFileName)
];
}
private static string BuildDesktopEntryContent(string executablePath)
{
var escapedExecutablePath = executablePath.Replace("\"", "\\\"", StringComparison.Ordinal);
return
"[Desktop Entry]\n" +
"Type=Application\n" +
"Version=1.0\n" +
"Name=LanMountainDesktop\n" +
"Comment=LanMountainDesktop desktop shell\n" +
$"Exec=\"{escapedExecutablePath}\" %U\n" +
$"Icon={IconName}\n" +
"Terminal=false\n" +
"Categories=Utility;Education;\n" +
"StartupWMClass=LanMountainDesktop\n";
}
private static void WriteFileIfChanged(string filePath, string content)
{
try
{
if (File.Exists(filePath))
{
var existing = File.ReadAllText(filePath);
if (string.Equals(existing, content, StringComparison.Ordinal))
{
return;
}
}
}
catch
{
// Fall through to attempt writing the content.
}
File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void TryRunCommand(string fileName, params string[] arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return;
}
_ = process.WaitForExit(2_500);
}
catch
{
// Ignore missing command or update failures.
}
}
}

View File

@@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class LinuxDesktopEntryService
{
private static readonly Regex FieldCodeRegex =
new(@"%[fFuUdDnNickvm]", RegexOptions.Compiled);
public StartMenuFolderNode Load()
{
var root = new StartMenuFolderNode("All Apps", string.Empty);
if (!OperatingSystem.IsLinux())
{
return root;
}
var seenDesktopIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var applicationsRoot in EnumerateApplicationsRoots())
{
foreach (var desktopFilePath in EnumerateDesktopFilesSafe(applicationsRoot))
{
if (!TryParseDesktopEntry(desktopFilePath, applicationsRoot, out var appEntry))
{
continue;
}
if (seenDesktopIds.Add(appEntry.RelativePath))
{
root.Apps.Add(appEntry);
}
}
}
root.Apps.Sort((left, right) =>
string.Compare(left.DisplayName, right.DisplayName, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase));
return root;
}
private static IEnumerable<string> EnumerateApplicationsRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "applications"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "applications"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "applications"));
}
candidates.Add("/var/lib/flatpak/exports/share/applications");
candidates.Add("/var/lib/snapd/desktop/applications");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateDesktopFilesSafe(string applicationsRoot)
{
try
{
return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry)
{
appEntry = null!;
Dictionary<string, string> fields;
try
{
fields = ReadDesktopEntryFields(desktopFilePath);
}
catch
{
return false;
}
if (!fields.TryGetValue("Type", out var entryType) ||
!string.Equals(entryType, "Application", StringComparison.OrdinalIgnoreCase) ||
GetBooleanField(fields, "NoDisplay") ||
GetBooleanField(fields, "Hidden"))
{
return false;
}
var displayName = GetPreferredName(fields);
if (string.IsNullOrWhiteSpace(displayName))
{
return false;
}
if (!fields.TryGetValue("Exec", out var execValue) ||
!TryParseExec(execValue, out var launchExecutable, out var launchArguments))
{
return false;
}
if (fields.TryGetValue("TryExec", out var tryExecValue) &&
!string.IsNullOrWhiteSpace(tryExecValue) &&
!CommandExists(tryExecValue))
{
return false;
}
var desktopFileId = BuildDesktopFileId(desktopFilePath, applicationsRoot);
var iconValue = fields.TryGetValue("Icon", out var iconFieldValue)
? iconFieldValue
: string.Empty;
var workingDirectory = Path.IsPathRooted(launchExecutable)
? Path.GetDirectoryName(launchExecutable)
: null;
appEntry = new StartMenuAppEntry
{
DisplayName = displayName.Trim(),
FilePath = desktopFilePath,
RelativePath = desktopFileId,
IconPngBytes = LinuxIconService.TryGetIconPngBytes(iconValue, Path.GetDirectoryName(desktopFilePath)),
LaunchExecutable = launchExecutable,
LaunchArguments = launchArguments,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory
};
return true;
}
private static Dictionary<string, string> ReadDesktopEntryFields(string desktopFilePath)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var inDesktopEntrySection = false;
foreach (var rawLine in File.ReadLines(desktopFilePath))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith('[') && line.EndsWith(']'))
{
inDesktopEntrySection = string.Equals(line, "[Desktop Entry]", StringComparison.OrdinalIgnoreCase);
continue;
}
if (!inDesktopEntrySection)
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex >= line.Length - 1)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
fields[key] = value;
}
return fields;
}
private static bool GetBooleanField(IReadOnlyDictionary<string, string> fields, string key)
{
return fields.TryGetValue(key, out var value) &&
bool.TryParse(value, out var result) &&
result;
}
private static string GetPreferredName(IReadOnlyDictionary<string, string> fields)
{
if (TryGetLocalizedField(fields, "Name", out var localizedName))
{
return localizedName;
}
return fields.TryGetValue("Name", out var fallbackName)
? fallbackName
: string.Empty;
}
private static bool TryGetLocalizedField(IReadOnlyDictionary<string, string> fields, string baseKey, out string value)
{
value = string.Empty;
var uiCulture = CultureInfo.CurrentUICulture;
var candidates = new[]
{
$"{baseKey}[{uiCulture.Name}]",
$"{baseKey}[{uiCulture.TwoLetterISOLanguageName}]"
};
foreach (var key in candidates)
{
if (fields.TryGetValue(key, out var localizedValue) &&
!string.IsNullOrWhiteSpace(localizedValue))
{
value = localizedValue;
return true;
}
}
return false;
}
private static string BuildDesktopFileId(string desktopFilePath, string applicationsRoot)
{
var relativePath = Path.GetRelativePath(applicationsRoot, desktopFilePath)
.Replace(Path.DirectorySeparatorChar, '-')
.Replace(Path.AltDirectorySeparatorChar, '-');
return relativePath.Trim();
}
private static bool TryParseExec(string execValue, out string launchExecutable, out List<string> launchArguments)
{
launchExecutable = string.Empty;
launchArguments = [];
var tokens = TokenizeExec(execValue);
if (tokens.Count == 0)
{
return false;
}
var cleanedTokens = new List<string>(tokens.Count);
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
var normalizedToken = token.Replace("%%", "%", StringComparison.Ordinal);
if (normalizedToken.Length == 2 && normalizedToken[0] == '%')
{
continue;
}
normalizedToken = FieldCodeRegex.Replace(normalizedToken, string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedToken))
{
continue;
}
cleanedTokens.Add(normalizedToken);
}
if (cleanedTokens.Count == 0)
{
return false;
}
launchExecutable = cleanedTokens[0];
launchArguments = cleanedTokens.Skip(1).ToList();
return true;
}
private static List<string> TokenizeExec(string execValue)
{
var tokens = new List<string>();
var current = new StringBuilder();
var inQuotes = false;
char quoteChar = '\0';
foreach (var c in execValue)
{
if ((c == '"' || c == '\'') &&
(!inQuotes || quoteChar == c))
{
if (inQuotes)
{
inQuotes = false;
quoteChar = '\0';
}
else
{
inQuotes = true;
quoteChar = c;
}
continue;
}
if (char.IsWhiteSpace(c) && !inQuotes)
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
continue;
}
current.Append(c);
}
if (current.Length > 0)
{
tokens.Add(current.ToString());
}
return tokens;
}
private static bool CommandExists(string command)
{
var trimmedCommand = command.Trim();
if (string.IsNullOrWhiteSpace(trimmedCommand))
{
return false;
}
if (Path.IsPathRooted(trimmedCommand))
{
return File.Exists(trimmedCommand);
}
var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var pathEntry in pathEntries)
{
try
{
var candidate = Path.Combine(pathEntry, trimmedCommand);
if (File.Exists(candidate))
{
return true;
}
}
catch
{
// Ignore malformed PATH entries.
}
}
return false;
}
}

View File

@@ -0,0 +1,214 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace LanMountainDesktop.Services;
internal static class LinuxIconService
{
private static readonly string[] SupportedRasterExtensions =
[
".png",
".ico"
];
private static readonly Regex SizeDirectoryRegex =
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
{
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
{
return null;
}
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
{
if (TryReadIconBytes(candidatePath, out var bytes))
{
return bytes;
}
}
return null;
}
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
{
if (Path.HasExtension(iconKey))
{
var directPath = ExpandHome(iconKey);
if (Path.IsPathRooted(directPath))
{
yield return directPath;
}
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
{
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
}
yield break;
}
var resolvedThemePath = ResolveThemedIconPath(iconKey);
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
{
yield return resolvedThemePath;
}
}
private static string? ResolveThemedIconPath(string iconName)
{
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
}
private static string? FindBestMatchingIconPath(string iconName)
{
var candidates = new List<(string Path, int Score)>();
foreach (var iconRoot in EnumerateIconRoots())
{
foreach (var extension in SupportedRasterExtensions)
{
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
{
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
}
}
}
return candidates
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Path.Length)
.Select(candidate => candidate.Path)
.FirstOrDefault();
}
private static IEnumerable<string> EnumerateIconRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "icons"));
candidates.Add(Path.Combine(dataHome, "pixmaps"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "icons"));
candidates.Add(Path.Combine(dataDir, "pixmaps"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".icons"));
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
}
candidates.Add("/var/lib/flatpak/exports/share/icons");
candidates.Add("/var/lib/snapd/desktop/icons");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
{
try
{
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
{
bytes = [];
try
{
var extension = Path.GetExtension(filePath);
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
!File.Exists(filePath))
{
return false;
}
bytes = File.ReadAllBytes(filePath);
return bytes.Length > 0;
}
catch
{
return false;
}
}
private static int ScoreIconPath(string filePath)
{
var score = 0;
var extension = Path.GetExtension(filePath);
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
{
score += 4_000;
}
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
{
score += 2_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 8_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 1_000;
}
var match = SizeDirectoryRegex.Match(filePath);
if (match.Success &&
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
{
score += Math.Min(size, 512);
}
return score;
}
private static string ExpandHome(string path)
{
if (!path.StartsWith("~", StringComparison.Ordinal))
{
return path;
}
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homeDirectory))
{
return path;
}
return path.Length == 1
? homeDirectory
: Path.Combine(homeDirectory, path[2..]);
}
}

View File

@@ -46,6 +46,8 @@ public sealed class LocalizationService
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
{
@@ -62,4 +64,3 @@ public sealed class LocalizationService
return result;
}
}

View File

@@ -0,0 +1,19 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public enum PluginCatalogSourceKind
{
Package = 0,
Manifest = 1
}
public sealed record PluginCatalogEntry(
PluginManifest Manifest,
string SourcePath,
bool IsPackage,
bool IsEnabled,
bool IsLoaded,
string? ErrorMessage,
int SettingsPageCount,
int WidgetCount);

View File

@@ -0,0 +1,11 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public sealed record PluginSettingsPageContribution(
LoadedPlugin Plugin,
PluginSettingsPageRegistration Registration);
public sealed record PluginDesktopComponentContribution(
LoadedPlugin Plugin,
PluginDesktopComponentRegistration Registration);

View File

@@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public sealed class PluginRuntimeService : IDisposable
{
private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new();
private readonly List<LoadedPlugin> _loadedPlugins = [];
private readonly List<PluginLoadResult> _loadResults = [];
private readonly List<PluginCatalogEntry> _catalog = [];
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
public PluginRuntimeService()
{
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_loader = new PluginLoader(CreateOptions());
}
public string PluginsDirectory { get; }
public IReadOnlyList<LoadedPlugin> LoadedPlugins => _loadedPlugins;
public IReadOnlyList<PluginLoadResult> LoadResults => _loadResults;
public IReadOnlyList<PluginCatalogEntry> Catalog => _catalog;
public IReadOnlyList<PluginSettingsPageContribution> SettingsPages => _settingsPages;
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
public void LoadInstalledPlugins()
{
Directory.CreateDirectory(PluginsDirectory);
UnloadInstalledPlugins();
var disabledPluginIds = GetDisabledPluginIds();
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["HostApplicationName"] = "LanMountainDesktop",
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
};
var discoveryFailures = new List<PluginLoadResult>();
var candidates = DiscoverCandidates(discoveryFailures);
_loadResults.AddRange(discoveryFailures);
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
if (!selectedPluginIds.Add(candidate.Manifest.Id))
{
var duplicateFailure = PluginLoadResult.Failure(
candidate.SourcePath,
candidate.Manifest,
new InvalidOperationException(
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
_loadResults.Add(duplicateFailure);
continue;
}
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
if (!isEnabled)
{
_catalog.Add(new PluginCatalogEntry(
candidate.Manifest,
candidate.SourcePath,
candidate.SourceKind == PluginCatalogSourceKind.Package,
false,
false,
null,
0,
0));
continue;
}
var loadResult = candidate.SourceKind switch
{
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
candidate.SourcePath,
PluginsDirectory,
services: null,
hostProperties),
_ => _loader.LoadFromManifest(
candidate.SourcePath,
services: null,
hostProperties)
};
_loadResults.Add(loadResult);
if (loadResult.IsSuccess && loadResult.LoadedPlugin is not null)
{
_loadedPlugins.Add(loadResult.LoadedPlugin);
CollectContributions(loadResult.LoadedPlugin);
_catalog.Add(new PluginCatalogEntry(
loadResult.LoadedPlugin.Manifest,
loadResult.SourcePath,
candidate.SourceKind == PluginCatalogSourceKind.Package,
true,
true,
null,
loadResult.LoadedPlugin.SettingsPages.Count,
loadResult.LoadedPlugin.DesktopComponents.Count));
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
continue;
}
_catalog.Add(new PluginCatalogEntry(
candidate.Manifest,
candidate.SourcePath,
candidate.SourceKind == PluginCatalogSourceKind.Package,
true,
false,
loadResult.Error?.Message,
0,
0));
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
}
if (_catalog.Count == 0 && discoveryFailures.Count == 0)
{
Debug.WriteLine($"[PluginRuntime] No .laapp packages or loose plugin manifests found under '{PluginsDirectory}'.");
}
}
public bool SetPluginEnabled(string pluginId, bool isEnabled)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return false;
}
var snapshot = _appSettingsService.Load();
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var changed = isEnabled
? disabledPluginIds.Remove(pluginId)
: disabledPluginIds.Add(pluginId);
if (!changed)
{
return false;
}
snapshot.DisabledPluginIds = disabledPluginIds
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToList();
_appSettingsService.Save(snapshot);
for (var i = 0; i < _catalog.Count; i++)
{
if (string.Equals(_catalog[i].Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
_catalog[i] = _catalog[i] with { IsEnabled = isEnabled };
}
}
return true;
}
public void Dispose()
{
UnloadInstalledPlugins();
}
private void UnloadInstalledPlugins()
{
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
{
_loadedPlugins[i].Dispose();
}
_loadedPlugins.Clear();
_loadResults.Clear();
_catalog.Clear();
_settingsPages.Clear();
_desktopComponents.Clear();
}
private HashSet<string> GetDisabledPluginIds()
{
var snapshot = _appSettingsService.Load();
return snapshot.DisabledPluginIds is { Count: > 0 }
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
private IReadOnlyList<PluginCandidate> DiscoverCandidates(List<PluginLoadResult> failures)
{
var candidates = new List<PluginCandidate>();
foreach (var packagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
{
try
{
var manifest = ReadManifestFromPackage(packagePath);
candidates.Add(new PluginCandidate(packagePath, manifest, PluginCatalogSourceKind.Package));
}
catch (Exception ex)
{
failures.Add(PluginLoadResult.Failure(packagePath, null, ex));
}
}
foreach (var manifestPath in EnumerateCandidatePaths("plugin.json"))
{
try
{
var manifest = PluginManifest.Load(manifestPath);
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.Manifest));
}
catch (Exception ex)
{
failures.Add(PluginLoadResult.Failure(manifestPath, null, ex));
}
}
return candidates
.OrderBy(candidate => candidate.SourceKind)
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
return Directory
.EnumerateFiles(PluginsDirectory, searchPattern, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
}
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 EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private static PluginLoaderOptions CreateOptions()
{
var options = new PluginLoaderOptions();
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(Application).Assembly);
AddSharedAssembly(options, typeof(Control).Assembly);
AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly);
return options;
}
private static void AddSharedAssembly(PluginLoaderOptions options, Assembly assembly)
{
var assemblyName = assembly.GetName().Name;
if (!string.IsNullOrWhiteSpace(assemblyName))
{
options.SharedAssemblyNames.Add(assemblyName);
}
}
private void CollectContributions(LoadedPlugin loadedPlugin)
{
foreach (var settingsPage in loadedPlugin.SettingsPages)
{
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
}
foreach (var desktopComponent in loadedPlugin.DesktopComponents)
{
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
}
}
private sealed record PluginCandidate(
string SourcePath,
PluginManifest Manifest,
PluginCatalogSourceKind SourceKind);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed record WebView2RuntimeAvailability(
bool IsAvailable,
string? Version,
string Message);
public static class WebView2RuntimeProbe
{
private const string WebView2RuntimeClientId = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
private const string WebView2RuntimeKeyPath = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\" + WebView2RuntimeClientId;
public const string RuntimeDownloadUrl = "https://go.microsoft.com/fwlink/p/?LinkId=2124703";
public static WebView2RuntimeAvailability GetAvailability()
{
if (!OperatingSystem.IsWindows())
{
return new WebView2RuntimeAvailability(
IsAvailable: true,
Version: null,
Message: string.Empty);
}
try
{
var version = TryGetVersionFromWebView2Api();
if (string.IsNullOrWhiteSpace(version))
{
version = TryGetVersionFromRegistry();
}
if (!string.IsNullOrWhiteSpace(version))
{
return new WebView2RuntimeAvailability(
IsAvailable: true,
Version: version.Trim(),
Message: string.Empty);
}
return new WebView2RuntimeAvailability(
IsAvailable: false,
Version: null,
Message: $"WebView2 Runtime is missing. Install it from {RuntimeDownloadUrl} and restart the app.");
}
catch (Exception ex)
{
return new WebView2RuntimeAvailability(
IsAvailable: false,
Version: null,
Message: $"WebView2 runtime check failed: {ex.Message}");
}
}
public static string ResolveUserDataFolder()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
localAppData = AppContext.BaseDirectory;
}
var userDataFolder = Path.Combine(localAppData, "LanMountainDesktop", "WebView2");
Directory.CreateDirectory(userDataFolder);
return userDataFolder;
}
private static string? TryGetVersionFromWebView2Api()
{
var type = Type.GetType(
"Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core",
throwOnError: false);
if (type is null)
{
return null;
}
var method = type.GetMethod(
"GetAvailableBrowserVersionString",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: Type.EmptyTypes,
modifiers: null);
if (method is null)
{
return null;
}
return method.Invoke(null, null) as string;
}
[SupportedOSPlatform("windows")]
private static string? TryGetVersionFromRegistry()
{
return TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64)
?? TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32)
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64)
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32);
}
[SupportedOSPlatform("windows")]
private static string? TryReadVersionFromRegistry(RegistryHive hive, RegistryView view)
{
try
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var runtimeKey = baseKey.OpenSubKey(WebView2RuntimeKeyPath, writable: false);
if (runtimeKey is null)
{
return null;
}
var value = runtimeKey.GetValue("pv") as string;
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed class WindowsStartupService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "LanMountainDesktop";
private readonly string _startupCommand;
public WindowsStartupService()
{
var processPath = Environment.ProcessPath;
_startupCommand = string.IsNullOrWhiteSpace(processPath)
? string.Empty
: $"\"{processPath}\"";
}
public bool IsEnabled()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
return runKey?.GetValue(ValueName) is string value &&
!string.IsNullOrWhiteSpace(value);
}
catch
{
return false;
}
}
public bool SetEnabled(bool enabled)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
if (enabled && string.IsNullOrWhiteSpace(_startupCommand))
{
return false;
}
try
{
using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath);
if (runKey is null)
{
return false;
}
if (enabled)
{
runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String);
}
else
{
runKey.DeleteValue(ValueName, throwOnMissingValue: false);
}
return IsEnabled() == enabled;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Services;
public static class WorldClockTimeZoneCatalog
{
public const int ClockCount = 4;
private static readonly string[][] DefaultTimeZoneCandidates =
[
["China Standard Time", "Asia/Shanghai"],
["GMT Standard Time", "Europe/London", "UTC"],
["AUS Eastern Standard Time", "Australia/Sydney"],
["Eastern Standard Time", "America/New_York"]
];
private static readonly Dictionary<string, string[]> CrossPlatformAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = ["Asia/Shanghai"],
["Asia/Shanghai"] = ["China Standard Time"],
["GMT Standard Time"] = ["Europe/London", "UTC"],
["Europe/London"] = ["GMT Standard Time", "UTC"],
["AUS Eastern Standard Time"] = ["Australia/Sydney"],
["Australia/Sydney"] = ["AUS Eastern Standard Time"],
["Eastern Standard Time"] = ["America/New_York"],
["America/New_York"] = ["Eastern Standard Time"],
["UTC"] = ["Etc/UTC"],
["Etc/UTC"] = ["UTC"],
["Tokyo Standard Time"] = ["Asia/Tokyo"],
["Asia/Tokyo"] = ["Tokyo Standard Time"]
};
public static IReadOnlyList<string> NormalizeTimeZoneIds(IEnumerable<string>? configuredIds)
{
var available = TimeZoneInfo.GetSystemTimeZones();
return NormalizeTimeZoneIds(configuredIds, available);
}
public static IReadOnlyList<string> NormalizeTimeZoneIds(
IEnumerable<string>? configuredIds,
IReadOnlyList<TimeZoneInfo> availableTimeZones)
{
var availableById = BuildAvailableTimeZoneLookup(availableTimeZones);
var requested = configuredIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToList() ?? [];
var normalized = new List<string>(ClockCount);
for (var index = 0; index < ClockCount; index++)
{
var requestedId = index < requested.Count ? requested[index] : null;
var resolved = ResolveAvailableId(requestedId, availableById) ??
ResolveDefaultId(index, availableById) ??
TimeZoneInfo.Local.Id;
normalized.Add(resolved);
}
return normalized;
}
public static TimeZoneInfo ResolveTimeZoneOrLocal(string? timeZoneId)
{
if (TryResolveTimeZone(timeZoneId, out var resolved))
{
return resolved;
}
return TimeZoneInfo.Local;
}
private static Dictionary<string, TimeZoneInfo> BuildAvailableTimeZoneLookup(
IReadOnlyList<TimeZoneInfo> availableTimeZones)
{
return availableTimeZones
.Where(zone => !string.IsNullOrWhiteSpace(zone.Id))
.GroupBy(zone => zone.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
}
private static string? ResolveDefaultId(
int slotIndex,
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
{
var clampedIndex = Math.Clamp(slotIndex, 0, ClockCount - 1);
foreach (var candidateId in DefaultTimeZoneCandidates[clampedIndex])
{
var resolved = ResolveAvailableId(candidateId, availableById);
if (!string.IsNullOrWhiteSpace(resolved))
{
return resolved;
}
}
return null;
}
private static string? ResolveAvailableId(
string? candidateId,
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
{
if (string.IsNullOrWhiteSpace(candidateId))
{
return null;
}
var normalizedCandidate = candidateId.Trim();
if (availableById.TryGetValue(normalizedCandidate, out var exact))
{
return exact.Id;
}
if (TryResolveTimeZone(normalizedCandidate, out var resolvedZone) &&
availableById.TryGetValue(resolvedZone.Id, out var resolved))
{
return resolved.Id;
}
if (!CrossPlatformAliases.TryGetValue(normalizedCandidate, out var aliases))
{
return null;
}
foreach (var alias in aliases)
{
if (availableById.TryGetValue(alias, out var aliasZone))
{
return aliasZone.Id;
}
if (TryResolveTimeZone(alias, out var aliasResolvedZone) &&
availableById.TryGetValue(aliasResolvedZone.Id, out var mappedAlias))
{
return mappedAlias.Id;
}
}
return null;
}
private static bool TryResolveTimeZone(string? timeZoneId, out TimeZoneInfo timeZone)
{
timeZone = TimeZoneInfo.Local;
if (string.IsNullOrWhiteSpace(timeZoneId))
{
return false;
}
var normalizedId = timeZoneId.Trim();
if (TryFindTimeZone(normalizedId, out timeZone))
{
return true;
}
if (!CrossPlatformAliases.TryGetValue(normalizedId, out var aliases))
{
return false;
}
foreach (var alias in aliases)
{
if (TryFindTimeZone(alias, out timeZone))
{
return true;
}
}
return false;
}
private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone)
{
timeZone = TimeZoneInfo.Local;
try
{
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,12 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
</Styles.Resources>
</Styles>

View File

@@ -25,9 +25,9 @@
<Setter Property="Padding" Value="16,10" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
<BrushTransition Property="Background" Duration="0:0:0.12" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
</Transitions>
</Setter>
</Style>
@@ -150,7 +150,7 @@
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
<Setter Property="Transitions">
<Transitions>
<ThicknessTransition Property="Padding" Duration="0:0:0.2" Easing="QuarticEaseOut" />
<ThicknessTransition Property="Padding" Duration="{StaticResource FluttermotionToken.Duration.Slow}" Easing="QuarticEaseOut" />
</Transitions>
</Setter>
</Style>

View File

@@ -21,7 +21,7 @@
</Setter>
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
<Style.Animations>
<Animation Duration="0:0:0.32"
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
FillMode="Both"
Easing="0.22,1,0.36,1">
<KeyFrame Cue="0%">
@@ -53,9 +53,9 @@
<Setter Property="MinHeight" Value="34" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>
@@ -74,8 +74,8 @@
<Style Selector="Grid.settings-scope ComboBox">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>
@@ -87,8 +87,8 @@
<Style Selector="Grid.settings-scope ToggleSwitch">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>

View File

@@ -0,0 +1,17 @@
using System;
namespace LanMountainDesktop.Theme;
public static class FluttermotionToken
{
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(24);
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
public const string StandardBezier = "0.22, 1, 0.36, 1";
}

View File

@@ -1,17 +1,53 @@
using System;
using System.Globalization;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "\u5317\u4EAC",
["Asia/Shanghai"] = "\u5317\u4EAC",
["GMT Standard Time"] = "\u4F26\u6566",
["Europe/London"] = "\u4F26\u6566",
["AUS Eastern Standard Time"] = "\u6089\u5C3C",
["Australia/Sydney"] = "\u6089\u5C3C",
["Eastern Standard Time"] = "\u7EBD\u7EA6",
["America/New_York"] = "\u7EBD\u7EA6",
["Tokyo Standard Time"] = "\u4E1C\u4EAC",
["Asia/Tokyo"] = "\u4E1C\u4EAC",
["UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6",
["Etc/UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6"
};
private static readonly IReadOnlyDictionary<string, string> EnCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "Beijing",
["Asia/Shanghai"] = "Beijing",
["GMT Standard Time"] = "London",
["Europe/London"] = "London",
["AUS Eastern Standard Time"] = "Sydney",
["Australia/Sydney"] = "Sydney",
["Eastern Standard Time"] = "New York",
["America/New_York"] = "New York",
["Tokyo Standard Time"] = "Tokyo",
["Asia/Tokyo"] = "Tokyo",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
};
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
@@ -19,12 +55,20 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private const double DialSize = 258;
private const double Center = DialSize / 2;
private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty;
private readonly AppSettingsService _appSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private bool _dialInitialized;
private bool _handsInitialized;
private bool? _isNightModeApplied;
private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time");
private string _languageCode = "zh-CN";
private string _secondHandMode = ClockSecondHandMode.Tick;
private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12);
private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8);
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4);
@@ -40,6 +84,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
}
@@ -62,10 +108,34 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
_timeZoneService = null;
}
public void RefreshFromSettings()
{
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClock
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshFromSettings();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
_timer.Start();
}
@@ -187,17 +257,22 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
{
ApplyModeVisualIfNeeded();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode)
? now.Second + now.Millisecond / 1000d
: now.Second;
var minuteValue = now.Minute + secondValue / 60d;
var hourValue = (now.Hour % 12) + minuteValue / 60d;
var hourAngle = hourValue * 30d;
var minuteAngle = minuteValue * 6d;
var secondAngle = secondValue * 6d;
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 52, backwardLength: 6);
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 76, backwardLength: 8);
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 94, backwardLength: 18);
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
CityTextBlock.Text = isZh ? "\u5317\u4eac" : "Beijing";
CityTextBlock.Text = ResolveCityName(_clockTimeZone);
}
private void ApplyModeVisualIfNeeded()
@@ -299,6 +374,54 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
};
}
private void LoadClockSettings()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: componentSnapshot.DesktopClockTimeZoneId.Trim();
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
}
private void ApplySecondHandTimerInterval()
{
_timer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode)
? TimeSpan.FromMilliseconds(16)
: TimeSpan.FromSeconds(1);
}
private string ResolveCityName(TimeZoneInfo timeZone)
{
var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ZhCityNames
: EnCityNames;
if (cityNames.TryGetValue(timeZone.Id, out var cityName))
{
return cityName;
}
var normalized = timeZone.Id;
var slashIndex = normalized.LastIndexOf('/');
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
{
normalized = normalized[(slashIndex + 1)..];
}
normalized = normalized.Replace('_', ' ').Trim();
normalized = normalized
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)

View File

@@ -0,0 +1,73 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="560"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.AnalogClockWidgetSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="时钟设置"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="为单时钟选择时区。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="TimeZoneLabelTextBlock"
Text="时区"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="TimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="SecondHandModeLabelTextBlock"
Text="秒针方式"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<StackPanel Orientation="Horizontal"
Spacing="12">
<RadioButton x:Name="SecondHandTickRadioButton"
GroupName="desktop_clock_second_mode"
Content="跳针"
Checked="OnSecondHandModeChanged" />
<RadioButton x:Name="SecondHandSweepRadioButton"
GroupName="desktop_clock_second_mode"
Content="扫针"
Checked="OnSecondHandModeChanged" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "中国标准时间",
["Asia/Shanghai"] = "中国标准时间",
["GMT Standard Time"] = "格林威治标准时间",
["Europe/London"] = "格林威治标准时间",
["AUS Eastern Standard Time"] = "澳大利亚东部标准时间",
["Australia/Sydney"] = "澳大利亚东部标准时间",
["Eastern Standard Time"] = "美国东部标准时间",
["America/New_York"] = "美国东部标准时间",
["Tokyo Standard Time"] = "日本标准时间",
["Asia/Tokyo"] = "日本标准时间",
["UTC"] = "协调世界时",
["Etc/UTC"] = "协调世界时"
};
private readonly AppSettingsService _appSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty;
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
private string _selectedTimeZoneId = string.Empty;
private string _secondHandMode = ClockSecondHandMode.Tick;
public event EventHandler? SettingsChanged;
public AnalogClockWidgetSettingsWindow()
{
InitializeComponent();
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClock
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: componentSnapshot.DesktopClockTimeZoneId.Trim();
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
_allTimeZones = _timeZoneService
.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("desktop_clock.settings.title", "时钟设置");
DescriptionTextBlock.Text = L("desktop_clock.settings.desc", "为单时钟选择时区。");
TimeZoneLabelTextBlock.Text = L("desktop_clock.settings.timezone_label", "时区");
SecondHandModeLabelTextBlock.Text = L("desktop_clock.settings.second_mode_label", "秒针方式");
SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
}
private void PopulateTimeZoneComboBox()
{
_suppressEvents = true;
try
{
TimeZoneComboBox.Items.Clear();
foreach (var timeZone in _allTimeZones)
{
TimeZoneComboBox.Items.Add(new ComboBoxItem
{
Tag = timeZone.Id,
Content = GetLocalizedTimeZoneDisplayName(timeZone)
});
}
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
new[] { _selectedTimeZoneId },
_allTimeZones)[0];
_selectedTimeZoneId = normalizedId;
var selected = TimeZoneComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, normalizedId, StringComparison.OrdinalIgnoreCase));
TimeZoneComboBox.SelectedItem = selected ?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode);
SecondHandTickRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Tick,
StringComparison.OrdinalIgnoreCase);
SecondHandSweepRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Sweep,
StringComparison.OrdinalIgnoreCase);
}
finally
{
_suppressEvents = false;
}
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var selectedId = (TimeZoneComboBox.SelectedItem as ComboBoxItem)?.Tag as string;
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
new[] { selectedId ?? _selectedTimeZoneId },
_allTimeZones)[0];
_selectedTimeZoneId = normalizedId;
_secondHandMode = GetSelectedSecondHandMode();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.DesktopClockTimeZoneId = normalizedId;
snapshot.DesktopClockSecondHandMode = _secondHandMode;
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSecondHandMode()
{
return SecondHandSweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ResolveZhDisplayName(timeZone)
: ResolveEnDisplayName(timeZone);
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}";
}
private static string ResolveZhDisplayName(TimeZoneInfo timeZone)
{
if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
{
return localizedName;
}
return string.IsNullOrWhiteSpace(timeZone.StandardName)
? timeZone.DisplayName
: timeZone.StandardName;
}
private static string ResolveEnDisplayName(TimeZoneInfo timeZone)
{
if (!string.IsNullOrWhiteSpace(timeZone.StandardName))
{
return timeZone.StandardName;
}
return timeZone.DisplayName;
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,110 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Baidu hot search settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure source, auto refresh and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="SourceLabelTextBlock"
Text="Data source"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="SourceComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="SourceOfficialItem"
Tag="Official"
Content="Official Source" />
<ComboBoxItem x:Name="SourceThirdPartyRssItem"
Tag="ThirdPartyRss"
Content="Third-party RSS" />
</ComboBox>
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRefreshLabelTextBlock"
Text="Auto refresh"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRefreshCheckBox"
Content="Enable auto refresh"
Checked="OnAutoRefreshChanged"
Unchecked="OnAutoRefreshChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Refresh interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency15mItem"
Tag="15"
Content="15 min" />
<ComboBoxItem x:Name="Frequency30mItem"
Tag="30"
Content="30 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency3hItem"
Tag="180"
Content="3 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BaiduHotSearchSettingsWindow : UserControl
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
public event EventHandler? SettingsChanged;
public BaiduHotSearchSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var sourceType = BaiduHotSearchSourceTypes.Normalize(componentSnapshot.BaiduHotSearchSourceType);
var enabled = componentSnapshot.BaiduHotSearchAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
_suppressEvents = true;
SelectSourceType(sourceType);
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("baiduhot.settings.title", "Baidu hot search settings");
DescriptionTextBlock.Text = L("baiduhot.settings.desc", "Configure source, auto refresh and refresh interval.");
SourceLabelTextBlock.Text = L("baiduhot.settings.source_label", "Data source");
SourceOfficialItem.Content = L("baiduhot.settings.source_official", "Official Source");
SourceThirdPartyRssItem.Content = L("baiduhot.settings.source_rss", "Third-party RSS");
AutoRefreshLabelTextBlock.Text = L("baiduhot.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("baiduhot.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("baiduhot.settings.frequency_label", "Refresh interval");
ApplyFrequencyLocalization();
}
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var enabled = AutoRefreshCheckBox.IsChecked == true;
FrequencyCardBorder.IsVisible = enabled;
SaveState();
}
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = _componentSettingsService.Load();
snapshot.BaiduHotSearchSourceType = GetSelectedSourceType();
snapshot.BaiduHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSourceType()
{
if (SourceComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string sourceTag)
{
return BaiduHotSearchSourceTypes.Normalize(sourceTag);
}
return BaiduHotSearchSourceTypes.Official;
}
private int GetSelectedInterval()
{
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes))
{
return NormalizeInterval(minutes);
}
return 15;
}
private void SelectSourceType(string sourceType)
{
var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType);
var selected = SourceComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string sourceTag &&
string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private void SelectInterval(int intervalMinutes)
{
var selected = FrequencyComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private void InitializeFrequencyOptions()
{
FrequencyComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
FrequencyComboBox.Items.Add(new ComboBoxItem
{
Tag = minutes.ToString(),
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
});
}
}
private void ApplyFrequencyLocalization()
{
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
{
if (item.Tag is not string tagText ||
!int.TryParse(tagText, out var minutes))
{
continue;
}
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
}
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,189 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="BrandTextBlock"
Text="百度热搜"
Foreground="#2932E1"
FontSize="24"
FontWeight="Bold"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="34"
Height="34"
CornerRadius="17"
Background="#EFF1F5"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
ToolTip.Tip="刷新"
Click="OnRefreshButtonClick">
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
Foreground="#5E6671"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,623 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const int MaxDisplayItemCount = 4;
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(15)
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly List<BaiduHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _hotItemVisuals = [];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private sealed record HotItemVisual(
Border Host,
Grid RowGrid,
TextBlock IndexTextBlock,
TextBlock TitleTextBlock);
public BaiduHotSearchWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem1TextBlock.FontFamily = MiSansFontFamily;
HotItem2TextBlock.FontFamily = MiSansFontFamily;
HotItem3TextBlock.FontFamily = MiSansFontFamily;
HotItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRefreshSettings();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
UpdateRefreshButtonState();
_ = RefreshHotSearchAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: true);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
_ = sender;
await RefreshHotSearchAsync(forceRefresh: true);
e.Handled = true;
}
private async Task RefreshHotSearchAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
UpdateRefreshButtonState();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new BaiduHotSearchQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
SourceType: _sourceType,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetBaiduHotSearchAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private void ApplySnapshot(BaiduHotSearchSnapshot snapshot)
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
foreach (var item in snapshot.Items)
{
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
{
continue;
}
_activeItems.Add(item);
if (_activeItems.Count >= MaxDisplayItemCount)
{
break;
}
}
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var loadingText = L("baiduhot.widget.loading_item", "加载中...");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = loadingText;
}
StatusTextBlock.Text = L("baiduhot.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = fallbackText;
}
StatusTextBlock.Text = L("baiduhot.widget.fetch_failed", "热搜获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Border host ||
host.Tag is null ||
!int.TryParse(host.Tag.ToString(), out var index) ||
index < 0 ||
index >= _activeItems.Count)
{
return;
}
TryOpenUrl(_activeItems[index].Url);
e.Handled = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.84, 1.26);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
var minTopRowHeight = Math.Clamp(22 * softScale, 18, 34);
var topRowHeight = Math.Clamp(availableRowsHeight * 0.30, minTopRowHeight, 54);
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
if (lineRowHeight < minLineRowHeight)
{
lineRowHeight = minLineRowHeight;
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
}
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
}
}
BrandTextBlock.FontSize = Math.Clamp(topRowHeight * 0.48, 12, 24);
BrandTextBlock.MaxWidth = Math.Max(80, innerWidth - Math.Clamp(topRowHeight * 0.84, 20, 46));
var refreshButtonSize = Math.Clamp(topRowHeight * 0.84, 20, 46);
RefreshButton.Width = refreshButtonSize;
RefreshButton.Height = refreshButtonSize;
RefreshButton.CornerRadius = new CornerRadius(refreshButtonSize / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(refreshButtonSize * 0.46, 10, 20);
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
foreach (var visual in _hotItemVisuals)
{
visual.RowGrid.ColumnSpacing = lineColumnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 0)
{
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
}
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.IndexTextBlock.FontSize = indexFont;
visual.IndexTextBlock.MaxWidth = indexWidth;
visual.TitleTextBlock.FontSize = itemFont;
visual.TitleTextBlock.MaxWidth = itemTextWidth;
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
{
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
visual.Host.IsHitTestVisible = enabled;
visual.Host.Opacity = enabled ? 1.0 : 0.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private void UpdateRefreshButtonState()
{
var enabled = _isAttached && !_isRefreshing;
RefreshButton.IsEnabled = enabled;
RefreshButton.Opacity = enabled ? 1.0 : 0.65;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void ApplyAutoRefreshSettings()
{
var enabled = true;
var intervalMinutes = 15;
var sourceType = BaiduHotSearchSourceTypes.Official;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
_sourceType = sourceType;
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
if (!_isAttached)
{
return;
}
if (_autoRefreshEnabled)
{
if (!_refreshTimer.IsEnabled)
{
_refreshTimer.Start();
}
}
else if (_refreshTimer.IsEnabled)
{
_refreshTimer.Stop();
}
}
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
{
if (minutes <= 0)
{
return 15;
}
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedAutoRefreshIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(15);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void TryOpenUrl(string? rawUrl)
{
var normalizedUrl = NormalizeHttpUrl(rawUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

@@ -0,0 +1,87 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Bilibili hot search settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure auto refresh and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRefreshLabelTextBlock"
Text="Auto refresh"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRefreshCheckBox"
Content="Enable auto refresh"
Checked="OnAutoRefreshChanged"
Unchecked="OnAutoRefreshChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Refresh interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency15mItem"
Tag="15"
Content="15 min" />
<ComboBoxItem x:Name="Frequency30mItem"
Tag="30"
Content="30 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency3hItem"
Tag="180"
Content="3 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BilibiliHotSearchSettingsWindow : UserControl
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
public event EventHandler? SettingsChanged;
public BilibiliHotSearchSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var enabled = componentSnapshot.BilibiliHotSearchAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
_suppressEvents = true;
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("bilihot.settings.title", "Bilibili hot search settings");
DescriptionTextBlock.Text = L("bilihot.settings.desc", "Configure auto refresh and refresh interval.");
AutoRefreshLabelTextBlock.Text = L("bilihot.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("bilihot.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("bilihot.settings.frequency_label", "Refresh interval");
ApplyFrequencyLocalization();
}
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var enabled = AutoRefreshCheckBox.IsChecked == true;
FrequencyCardBorder.IsVisible = enabled;
SaveState();
}
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = _componentSettingsService.Load();
snapshot.BilibiliHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private int GetSelectedInterval()
{
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes))
{
return NormalizeInterval(minutes);
}
return 15;
}
private void SelectInterval(int intervalMinutes)
{
var selected = FrequencyComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private void InitializeFrequencyOptions()
{
FrequencyComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
FrequencyComboBox.Items.Add(new ComboBoxItem
{
Tag = minutes.ToString(),
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
});
}
}
private void ApplyFrequencyLocalization()
{
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
{
if (item.Tag is not string tagText ||
!int.TryParse(tagText, out var minutes))
{
continue;
}
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
}
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,196 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<Border x:Name="SearchBoxBorder"
Height="38"
CornerRadius="19"
Background="#F1F2F4"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,0"
HorizontalAlignment="Left"
PointerPressed="OnSearchBoxPointerPressed">
<Grid ColumnDefinitions="Auto,Auto"
ColumnSpacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="SearchGlyphIcon"
Symbol="Search"
IconVariant="Regular"
Foreground="#7A8088"
FontSize="17"
VerticalAlignment="Center" />
<TextBlock x:Name="SearchEntryTextBlock"
Grid.Column="1"
Text="Search"
Foreground="#7A8088"
FontSize="18"
FontWeight="Medium"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<TextBlock x:Name="TopRightTitleTextBlock"
Grid.Column="1"
Text="bilibili热搜"
Foreground="#F44C9F"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

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