Compare commits

...

9 Commits

Author SHA1 Message Date
lincube
ce5acf5bd7 fix.修复了快捷方式组件无法正常透明的问题。 2026-04-13 16:26:23 +08:00
lincube
b933f3badf changed.调整了开发者选项 2026-04-13 13:14:58 +08:00
lincube
76d13ac024 feat.开发者调试工具 2026-04-13 08:02:47 +08:00
lincube
99a82d64e3 change.插件设置支持View 2026-04-13 01:23:11 +08:00
lincube
692ca3de3d Update CHANGELOG.md 2026-04-12 20:20:15 +08:00
lincube
d62226ffa0 fix. 试验性的修复了轻量版的Dotnet问题 2026-04-12 17:28:33 +08:00
lincube
91ab52ce8b change.插件sdk更新 2026-04-12 13:52:52 +08:00
lincube
4a89c2388b feat.便签组件 2026-04-12 12:14:25 +08:00
lincube
cb96180118 feat.白板笔色自适应主题 2026-04-12 01:10:12 +08:00
47 changed files with 1917 additions and 152 deletions

View File

@@ -109,21 +109,36 @@ jobs:
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:$selfContained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:SelfContained=$selfContained `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if ($selfContained) {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} else {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
Write-Host "Published to: $publishDir"
Write-Host "Self-contained: $selfContained"

View File

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

View File

@@ -1,18 +1,82 @@
# 更新日志 / Changelog
## [0.8.3.2] - 2026-04-09
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
### 新增 (Added)
-**开发者调试工具**: 新增开发者调试工具,优化插件开发体验
- 提供便捷的调试功能,帮助开发者快速定位和解决问题
- 支持插件运行时状态监控和日志查看
- 提升插件开发效率和调试体验
### 变更 (Changed)
-**插件设置页面支持 View 展示**: 插件设置页面现在支持使用 View 进行展示
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
- 提供更灵活的设置页面展示方式,提升插件用户体验
- 兼容原有的设置方式,平滑过渡
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
### 新增 (Added)
-**便签组件**: 全新便签组件上线,支持 Markdown 语法
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
-**白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
### 变更 (Changed)
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
- 提升视觉一致性和用户体验
- 统一组件风格,与整体设计语言保持协调
### 修复 (Fixed)
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
- 优化组件渲染逻辑,确保新闻内容正确展示
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
- 优化组件布局和渲染,确保课程信息正确显示
- 🐛 **轻量版 .NET 10 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 10 环境下的依赖问题
- 问题原因: 轻量版与 .NET 10 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升与 .NET 10 的兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.2](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2) - 2026-04-09
### 新增 (Added)
-**应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
- 用户可在设置中选择是否显示应用图标的专属卡片背景
- 关闭后仅显示应用图标本身,更加简洁
- 支持动态切换,实时预览效果
### 变更 (Changed)
-
### 修复 (Fixed)
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
@@ -24,13 +88,15 @@
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
### 移除 (Removed)
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
---
***
## [0.8.3.1] - 2026-04-08
## [0.8.3.1](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1) - 2026-04-08
### 新增 (Added)
-**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式
@@ -38,38 +104,46 @@
- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed)
-
### 修复 (Fixed)
-
### 移除 (Removed)
-
---
***
所有重要的更改都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
---
***
## [格式示例]
## \[格式示例]
### 新增 (Added)
- 待发布的新功能
### 变更 (Changed)
- 待发布的变更
### 修复 (Fixed)
- 待发布的修复
### 移除 (Removed)
- 待发布的移除项
---
***
## 版本说明
### 版本号规则
@@ -101,10 +175,6 @@
- 🔒 **安全**: 安全相关
- 🌐 **国际化**: 国际化/本地化
---
***
## 链接
[Unreleased]: https://github.com/yourorg/LanMountainDesktop/compare/v0.8.3.2...HEAD
[0.8.3.2]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2
[0.8.3.1]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.0.0</Version>
<Version>4.0.1</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,10 +35,11 @@ public sealed class CornerRadiusStyleTests
Component: 24d),
ThemeVariant: "Light"));
// Preset resolution should return fixed values from tokens regardless of any legacy scale
// Preset resolution should return fixed values from tokens
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
Assert.Equal(20d, context.ResolveScaledCornerRadius(18d), 3);
Assert.Equal(15d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(18d, context.ResolveScaledCornerRadius(18d), 3);
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
}
@@ -60,8 +61,12 @@ public sealed class CornerRadiusStyleTests
96d,
appearanceContext);
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d), 3);
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d), 3);
// When min/max specified, value is clamped
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
// Component token access
Assert.Equal(24d, context.CornerRadiusTokens.Component, 3);
}
private sealed class NullServiceProvider : IServiceProvider

View File

@@ -47,4 +47,5 @@ public static class BuiltInComponentIds
public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox";
public const string DesktopShortcut = "DesktopShortcut";
public const string DesktopStickyNote = "DesktopStickyNote";
}

View File

@@ -327,6 +327,16 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStickyNote,
"Sticky Note",
"Notepad",
"Board",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBrowser,
"Browser",

View File

@@ -2,12 +2,12 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
</PropertyGroup>
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->

View File

@@ -154,6 +154,10 @@ public sealed class AppSettingsSnapshot
public List<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }
public string? DevPluginPath { get; set; }
#region Study Settings
public bool StudyEnabled { get; set; } = true;

View File

@@ -142,6 +142,12 @@ public sealed class ComponentSettingsSnapshot
#endregion
#region Sticky Note Component Settings (便)
public string StickyNoteContent { get; set; } = string.Empty;
#endregion
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

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

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services.PluginMarket;
@@ -204,6 +205,10 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
string? pluginId,
bool isBuiltIn)
{
var isDevModeEnabled = _settingsFacade.Settings
.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App)
.IsDevModeEnabled;
foreach (var pageType in assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(SettingsPageBase).IsAssignableFrom(type)))
{
@@ -214,6 +219,12 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
}
var category = isBuiltIn ? pageInfo.Category : SettingsPageCategory.Plugins;
if (category == SettingsPageCategory.Dev && !isDevModeEnabled)
{
continue;
}
var sortOrder = isBuiltIn ? pageInfo.SortOrder : 100 + pageInfo.SortOrder;
var title = ResolveLocalizedText(pageInfo.TitleLocalizationKey, pageInfo.Name);
var description = ResolveLocalizedText(pageInfo.DescriptionLocalizationKey, null);
@@ -256,6 +267,29 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
? null
: localizer.GetString(section.DescriptionLocalizationKey, section.DescriptionLocalizationKey);
Func<ISettingsPageHostContext, Control> factory;
if (section.CustomViewType is not null)
{
var customViewType = section.CustomViewType;
var pluginServices = loadedPlugin.Services;
factory = hostContext => CreatePage(pluginServices, customViewType, hostContext);
}
else
{
factory = hostContext =>
{
var page = new GeneratedPluginSettingsPage(
new PluginGeneratedSettingsPageViewModel(
_settingsFacade.Settings,
loadedPlugin.Manifest.Id,
section,
localizer));
page.InitializeHostContext(hostContext);
return page;
};
}
_pages.Add(new SettingsPageDescriptor(
pageId,
title,
@@ -270,17 +304,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
hidePageTitle: false,
useFullWidth: false,
groupId: null,
hostContext =>
{
var page = new GeneratedPluginSettingsPage(
new PluginGeneratedSettingsPageViewModel(
_settingsFacade.Settings,
loadedPlugin.Manifest.Id,
section,
localizer));
page.InitializeHostContext(hostContext);
return page;
}));
factory));
}
}

View File

@@ -72,6 +72,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_localizationService = new();
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
}
private string L(string key)
@@ -279,6 +280,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
var devModeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsDevModeEnabled), StringComparer.OrdinalIgnoreCase);
var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
@@ -291,14 +293,13 @@ internal sealed class SettingsWindowService : ISettingsWindowService
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
if (languageChanged)
if (languageChanged || devModeChanged)
{
var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);
_window.ReloadPages(devModeChanged ? "dev" : _viewModel.CurrentPageId);
_window.RefreshShellText();
}
@@ -311,6 +312,31 @@ internal sealed class SettingsWindowService : ISettingsWindowService
}, DispatcherPriority.Background);
}
private void OnAppSettingsSaved(string instanceId)
{
Dispatcher.UIThread.Post(() =>
{
if (_window is null || _viewModel is null)
{
return;
}
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var devPageVisible = _pageRegistry.GetPages().Any(p => p.PageId == "dev");
if (snapshot.IsDevModeEnabled && !devPageVisible)
{
_pageRegistry.Rebuild();
_window.ReloadPages("dev");
}
else if (!snapshot.IsDevModeEnabled && devPageVisible)
{
_pageRegistry.Rebuild();
_window.ReloadPages(null);
}
}, DispatcherPriority.Background);
}
private void ApplyTheme(SettingsWindow window)
{
var appearanceSnapshot = _appearanceThemeService.GetCurrent();

View File

@@ -3088,3 +3088,54 @@ public sealed class PluginGeneratedSettingsPageViewModel
public string? Description { get; }
}
public sealed partial class DevSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitializing;
public DevSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
_isInitializing = true;
LoadSettings();
_isInitializing = false;
}
[ObservableProperty]
private bool _isDevModeEnabled;
[ObservableProperty]
private string _devPluginPath = string.Empty;
partial void OnIsDevModeEnabledChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.IsDevModeEnabled), value);
}
partial void OnDevPluginPathChanged(string value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.DevPluginPath), value);
}
private void LoadSettings()
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
IsDevModeEnabled = snapshot.IsDevModeEnabled;
DevPluginPath = snapshot.DevPluginPath ?? string.Empty;
}
private void SaveField<T>(string key, T value)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var property = typeof(AppSettingsSnapshot).GetProperty(key);
if (property is not null && property.CanWrite)
{
property.SetValue(snapshot, value);
}
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
}
}

View File

@@ -452,6 +452,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopBlackboardLandscape,
"component.blackboard_landscape",
() => new WhiteboardWidget(baseWidthCells: 4)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStickyNote,
"component.sticky_note",
() => new StickyNoteWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBrowser,
"component.browser",

View File

@@ -8,9 +8,6 @@
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"

View File

@@ -25,6 +25,7 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
private bool _showBackground = true;
private double _currentCellSize = 48;
private bool _isDisposed;
private bool _chromeApplied;
private const double TapMovementThreshold = 10;
private const long TapTimeThresholdMs = 500;
@@ -40,9 +41,32 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
{
InitializeComponent();
DoubleTapped += OnDoubleTapped;
Loaded += OnLoaded;
UpdateDisplay();
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
// ApplyChrome() may have been called before the control was attached to the visual tree,
// causing FindResource() to fail. Re-apply now that resources are available.
if (!_chromeApplied)
{
ApplyChrome();
}
// Subscribe to theme changes so the background follows theme updates.
var themeService = HostAppearanceThemeProvider.GetOrCreate();
themeService.Changed += OnAppearanceThemeChanged;
}
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
if (_isDisposed || _showBackground)
{
ApplyChrome();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
@@ -258,13 +282,25 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
_chromeApplied = true;
return;
}
// 恢复默认的实心背景样式
RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
// FindResource requires the control to be attached to the visual tree.
// If it returns null, _chromeApplied stays false so OnLoaded will retry.
var background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush;
var borderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush;
if (background is null || borderBrush is null)
{
_chromeApplied = false;
return;
}
RootBorder.Background = background;
RootBorder.BorderBrush = borderBrush;
RootBorder.BorderThickness = new Thickness(1);
_chromeApplied = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
@@ -391,6 +427,10 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
}
_isDisposed = true;
var themeService = HostAppearanceThemeProvider.GetOrCreate();
themeService.Changed -= OnAppearanceThemeChanged;
_gestureStates.Clear();
}
}

View File

@@ -0,0 +1,51 @@
<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"
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
mc:Ignorable="d"
d:DesignWidth="200"
d:DesignHeight="200"
x:Class="LanMountainDesktop.Views.Components.StickyNoteWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="#E0C878"
BorderThickness="1">
<Grid>
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
Margin="14,14,14,10"
IsVisible="True" />
<TextBox x:Name="NoteTextBox"
AcceptsReturn="True"
TextWrapping="Wrap"
IsVisible="False"
Background="Transparent"
BorderThickness="0"
Margin="14,14,14,10"
FontFamily="Consolas,Cascadia Code,Courier New,monospace"
Foreground="#5D4E37" />
<Button x:Name="ToggleButton"
Width="28"
Height="28"
CornerRadius="14"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="4,4,4,0"
Padding="0"
Background="#00000010"
BorderThickness="0"
Click="OnToggleButtonClick">
<fi:SymbolIcon x:Name="ToggleIcon"
Symbol="Edit"
IconVariant="Regular"
FontSize="13"
Foreground="#8B7D5A" />
</Button>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,371 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components;
public partial class StickyNoteWidget : UserControl,
IDesktopComponentWidget,
IComponentPlacementContextAware,
IComponentSettingsContextAware,
IDesktopPageVisibilityAwareComponentWidget,
IDisposable
{
private static readonly Color LightNoteYellow = Color.FromRgb(0xFF, 0xF9, 0xC4);
private static readonly Color LightNoteBorder = Color.FromRgb(0xE0, 0xC8, 0x78);
private static readonly Color LightNoteForeground = Color.FromRgb(0x5D, 0x4E, 0x37);
private static readonly Color LightNoteHint = Color.FromRgb(0x8B, 0x7D, 0x5A);
private static readonly Color DarkNoteYellow = Color.FromRgb(0x5D, 0x52, 0x29);
private static readonly Color DarkNoteBorder = Color.FromRgb(0x7A, 0x6D, 0x3A);
private static readonly Color DarkNoteForeground = Color.FromRgb(0xE8, 0xE0, 0xC8);
private static readonly Color DarkNoteHint = Color.FromRgb(0xA0, 0x96, 0x70);
private string _componentId = BuiltInComponentIds.DesktopStickyNote;
private string _placementId = string.Empty;
private IComponentSettingsAccessor? _settingsAccessor;
private string _markdownContent = string.Empty;
private bool _isEditing;
private bool _isDirty;
private bool _isOnActivePage = true;
private bool _isEditMode;
private bool _disposed;
private bool _isApplyingPersistedContent;
private readonly DispatcherTimer _autoSaveTimer = new()
{
Interval = TimeSpan.FromSeconds(30)
};
private CancellationTokenSource? _renderDebounceCts;
public StickyNoteWidget()
{
InitializeComponent();
_autoSaveTimer.Tick += OnAutoSaveTimerTick;
NoteTextBox.TextChanged += OnNoteTextBoxTextChanged;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
ApplyNoteColors();
UpdateDisplay();
}
public void ApplyCellSize(double cellSize)
{
var scale = Math.Clamp(cellSize / 48d, 0.82, 2.2);
RootBorder.CornerRadius = new CornerRadius(
ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadiusValue(
new ComponentChromeContext(
_componentId,
_placementId,
Math.Max(1, cellSize),
Appearance.AppearanceCornerRadiusTokenFactory.Create(
Settings.Core.GlobalAppearanceSettings.DefaultCornerRadiusStyle))));
RootBorder.Padding = new Thickness(
Math.Clamp(2 * scale, 1, 4),
Math.Clamp(2 * scale, 1, 4));
var contentMargin = Math.Clamp(12 * scale, 6, 20);
MarkdownViewer.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
NoteTextBox.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
NoteTextBox.FontSize = Math.Clamp(13 * scale, 10, 22);
var buttonSize = Math.Clamp(28 * scale, 22, 40);
ToggleButton.Width = buttonSize;
ToggleButton.Height = buttonSize;
ToggleButton.CornerRadius = new CornerRadius(buttonSize / 2d);
ToggleButton.Margin = new Thickness(Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), 0);
ToggleIcon.FontSize = Math.Clamp(13 * scale, 10, 18);
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
if (_isDirty && !string.IsNullOrWhiteSpace(_placementId))
{
PersistNoteImmediately();
}
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopStickyNote
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
if (_isEditing)
{
ExitEditMode();
}
LoadPersistedContent();
}
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
{
_settingsAccessor = context.ComponentSettingsAccessor;
LoadPersistedContent();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_isOnActivePage = isOnActivePage;
_isEditMode = isEditMode;
ToggleButton.IsHitTestVisible = !isEditMode;
NoteTextBox.IsReadOnly = isEditMode;
if (isEditMode && _isEditing)
{
ExitEditMode();
}
}
private void OnToggleButtonClick(object? sender, RoutedEventArgs e)
{
if (_isEditing)
{
ExitEditMode();
}
else
{
EnterEditMode();
}
}
private void EnterEditMode()
{
_isEditing = true;
NoteTextBox.Text = _markdownContent;
MarkdownViewer.IsVisible = false;
NoteTextBox.IsVisible = true;
ToggleIcon.Symbol = Symbol.Checkmark;
Dispatcher.UIThread.Post(() => NoteTextBox.Focus(), DispatcherPriority.Input);
}
private void ExitEditMode()
{
_isEditing = false;
var editedContent = NoteTextBox.Text ?? string.Empty;
if (editedContent != _markdownContent)
{
_markdownContent = editedContent;
_isDirty = true;
}
NoteTextBox.IsVisible = false;
MarkdownViewer.IsVisible = true;
ToggleIcon.Symbol = Symbol.Edit;
UpdateDisplay();
if (_isDirty)
{
PersistNoteImmediately();
}
}
private void OnNoteTextBoxTextChanged(object? sender, TextChangedEventArgs e)
{
if (_isApplyingPersistedContent || !_isEditing)
{
return;
}
_isDirty = true;
if (!_autoSaveTimer.IsEnabled)
{
_autoSaveTimer.Start();
}
}
private void OnAutoSaveTimerTick(object? sender, EventArgs e)
{
_autoSaveTimer.Stop();
if (_isDirty && _isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
PersistNoteImmediately();
}
}
private void UpdateDisplay()
{
try
{
if (string.IsNullOrWhiteSpace(_markdownContent))
{
MarkdownViewer.Markdown = "*Click ✏️ to write a note...*";
return;
}
_renderDebounceCts?.Cancel();
_renderDebounceCts?.Dispose();
_renderDebounceCts = new CancellationTokenSource();
var token = _renderDebounceCts.Token;
Dispatcher.UIThread.Post(async () =>
{
try
{
await Task.Delay(150, token);
if (!token.IsCancellationRequested)
{
MarkdownViewer.Markdown = _markdownContent;
}
}
catch (OperationCanceledException) { }
});
}
catch (Exception ex)
{
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
}
}
private void LoadPersistedContent()
{
if (_settingsAccessor is null)
{
return;
}
try
{
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
_isApplyingPersistedContent = true;
_markdownContent = snapshot.StickyNoteContent ?? string.Empty;
_isDirty = false;
UpdateDisplay();
}
catch
{
_markdownContent = string.Empty;
UpdateDisplay();
}
finally
{
_isApplyingPersistedContent = false;
}
}
private void PersistNoteImmediately()
{
if (_settingsAccessor is null || _disposed)
{
return;
}
try
{
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
snapshot.StickyNoteContent = _markdownContent;
_settingsAccessor.SaveSnapshot(snapshot,
[nameof(ComponentSettingsSnapshot.StickyNoteContent)]);
_isDirty = false;
}
catch
{
}
}
private void ApplyNoteColors()
{
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
if (isDark)
{
RootBorder.Background = new SolidColorBrush(DarkNoteYellow);
RootBorder.BorderBrush = new SolidColorBrush(DarkNoteBorder);
NoteTextBox.Foreground = new SolidColorBrush(DarkNoteForeground);
ToggleIcon.Foreground = new SolidColorBrush(DarkNoteHint);
}
else
{
RootBorder.Background = new SolidColorBrush(LightNoteYellow);
RootBorder.BorderBrush = new SolidColorBrush(LightNoteBorder);
NoteTextBox.Foreground = new SolidColorBrush(LightNoteForeground);
ToggleIcon.Foreground = new SolidColorBrush(LightNoteHint);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
Application.Current!.ActualThemeVariantChanged += OnThemeVariantChanged;
ApplyNoteColors();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
Application.Current!.ActualThemeVariantChanged -= OnThemeVariantChanged;
if (_isDirty)
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
PersistNoteImmediately();
}
_autoSaveTimer.Stop();
}
private void OnThemeVariantChanged(object? sender, EventArgs e)
{
Dispatcher.UIThread.Post(ApplyNoteColors);
}
public void ForceSave()
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
if (_isDirty || _isEditing)
{
PersistNoteImmediately();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_autoSaveTimer.Stop();
_renderDebounceCts?.Cancel();
_renderDebounceCts?.Dispose();
if (_isDirty)
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
PersistNoteImmediately();
}
}
}

View File

@@ -40,13 +40,12 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _selectedInkColor = SKColors.Black;
private bool _isUserCustomColor;
private float _selectedInkThickness = 2.5f;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
private bool? _lastBitmapCacheEnabled;
private int _lastBitmapCacheSize;
private bool _noteDirty;
private int _noteLoadRevision;
private bool _disposed;
@@ -121,10 +120,11 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
settings.IgnorePressure = true;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
UpdateInkCanvasCacheSettings(forceRefresh: true);
}
public void ApplyCellSize(double cellSize)
@@ -158,7 +158,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
UpdateInkCanvasCacheSettings(forceRefresh: false);
}
private void ApplyThemeVisual(bool force)
@@ -169,6 +168,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return;
}
var wasNightMode = _isNightModeApplied;
_isNightModeApplied = isNightMode;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
@@ -177,9 +177,39 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
ApplyThemeDefaultInkColor(isNightMode, wasNightMode);
RefreshToolButtonVisuals();
}
private void ApplyThemeDefaultInkColor(bool isNightMode, bool? wasNightMode)
{
if (_isUserCustomColor || wasNightMode == isNightMode)
{
return;
}
var oldDefault = wasNightMode == true ? SKColors.White : SKColors.Black;
var newDefault = isNightMode ? SKColors.White : SKColors.Black;
if (_selectedInkColor == oldDefault)
{
_selectedInkColor = newDefault;
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
if (InkColorPicker is not null)
{
InkColorPicker.Color = new Color(
_selectedInkColor.Alpha,
_selectedInkColor.Red,
_selectedInkColor.Green,
_selectedInkColor.Blue);
}
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
@@ -431,7 +461,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
{
var color = e.NewColor;
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
var skColor = new SKColor(color.R, color.G, color.B, color.A);
_isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White;
SetInkColor(skColor);
}
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
@@ -713,7 +745,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
UpdateInkCanvasCacheSettings(forceRefresh: true);
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
InkCanvas.InvalidateVisual();
}
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
@@ -766,7 +799,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
UpdateInkCanvasCacheSettings(forceRefresh: true);
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private bool HasValidPersistenceContext()
@@ -784,47 +819,4 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return Array.Empty<InkStylusPoint>();
}
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
{
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
var longestSide = Math.Max(widthPx, heightPx);
var area = widthPx * heightPx;
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
if (!forceRefresh &&
_lastBitmapCacheEnabled == cacheEnabled &&
_lastBitmapCacheSize == cacheSize)
{
return;
}
_lastBitmapCacheEnabled = cacheEnabled;
_lastBitmapCacheSize = cacheSize;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IsBitmapCacheEnabled = cacheEnabled;
settings.MaxBitmapCacheSize = cacheSize;
try
{
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
if (cacheEnabled)
{
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
}
else
{
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
}
catch
{
// Keep drawing available even if the underlying cache backend rejects the cache update.
}
}
}

View File

@@ -4286,6 +4286,10 @@ public partial class MainWindow
{
whiteboard.ForceSaveNote();
}
else if (contentHost?.Child is StickyNoteWidget stickyNote)
{
stickyNote.ForceSave();
}
}
}
}

View File

@@ -36,7 +36,8 @@
<StackPanel Classes="about-page-container">
<Border x:Name="AboutHeroCard"
Classes="about-hero-card"
Height="240">
Height="240"
PointerPressed="OnAboutHeroCardPointerPressed">
<Image Source="/Assets/about_banner.png"
Stretch="Uniform"
HorizontalAlignment="Center"

View File

@@ -1,9 +1,16 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Views.SettingsPages;
@@ -19,6 +26,10 @@ namespace LanMountainDesktop.Views.SettingsPages;
public partial class AboutSettingsPage : SettingsPageBase
{
private const double HeroAspectRatio = 9d / 16d;
private const int DevModeActivationClicks = 5;
private int _heroCardClickCount;
private DateTime _lastHeroCardClickTime = DateTime.MinValue;
public AboutSettingsPage()
: this(new AboutSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
@@ -60,4 +71,81 @@ public partial class AboutSettingsPage : SettingsPageBase
AboutHeroCard.Height = targetHeight;
}
private void OnAboutHeroCardPointerPressed(object? sender, PointerPressedEventArgs e)
{
var now = DateTime.UtcNow;
var elapsed = now - _lastHeroCardClickTime;
if (elapsed.TotalSeconds > 3)
{
_heroCardClickCount = 1;
}
else
{
_heroCardClickCount++;
}
_lastHeroCardClickTime = now;
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (snapshot.IsDevModeEnabled)
{
if (_heroCardClickCount >= 3)
{
_heroCardClickCount = 0;
Debug.WriteLine("[AboutSettingsPage] Developer mode is already enabled.");
}
return;
}
var remaining = DevModeActivationClicks - _heroCardClickCount;
if (remaining <= 0)
{
_heroCardClickCount = 0;
PromptEnableDevMode(settingsFacade);
}
else if (remaining <= 2)
{
Debug.WriteLine($"[AboutSettingsPage] 再点击 {remaining} 次即可启用开发者模式。");
}
}
private async void PromptEnableDevMode(ISettingsFacadeService settingsFacade)
{
var dialog = new ContentDialog
{
Title = "启用开发者模式",
Content = "开发者模式提供了插件调试、热重载等高级功能,仅供开发和调试用途。\n\n" +
"请注意:开发者不对以非开发用途使用此功能造成的任何后果负责,也不接受以非开发用途使用时产生的 Bug 反馈。\n\n" +
"确定要启用开发者模式吗?",
PrimaryButtonText = "启用",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
snapshot.IsDevModeEnabled = true;
settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.IsDevModeEnabled)]);
AppLogger.Info("DevMode", "Developer mode enabled via About page activation.");
if (this.FindAncestorOfType<SettingsWindow>() is { } settingsWindow)
{
settingsWindow.RebuildAndNavigateToDevPage();
}
}
}

View File

@@ -0,0 +1,89 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.DevSettingsPage"
x:DataType="vm:DevSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<ui:InfoBar IsOpen="True"
IsClosable="False"
Severity="Warning"
Title="开发者模式"
Message="开发者模式仅供开发和调试用途。开发者不对以非开发用途使用此功能造成的任何后果负责。"
Margin="0,0,0,16" />
<ui:SettingsExpander Header="启用开发者模式"
Description="启用后可使用插件调试、开发者插件路径等高级功能">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="DeveloperBoard" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDevModeEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />
<ui:SettingsExpander Header="开发者插件路径"
Description="指定开发中的插件目录路径,无需打包即可直接加载。多个路径用分号分隔。">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="FolderLink" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding DevPluginPath}"
Watermark="C:\path\to\plugin\bin\Debug\net10.0"
Width="360"
MinWidth="200" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />
<ui:SettingsExpander Header="命令行参数"
Description="也可以通过命令行参数或环境变量指定开发者插件路径">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="WindowConsole" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<StackPanel Margin="0,8,0,0" Spacing="8">
<TextBlock Text="命令行参数:" FontWeight="SemiBold" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--dev-plugin &lt;path&gt; 或 -dp &lt;path&gt;"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="环境变量:" FontWeight="SemiBold" Margin="0,8,0,0" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="LMD_DEV_PLUGIN=&lt;path&gt;"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="其他参数:" FontWeight="SemiBold" Margin="0,8,0,0" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<StackPanel Spacing="4">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--dev-mode / -dev 启用开发者模式" />
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--hot-reload / -hr 启用热重载(预留)" />
</StackPanel>
</Border>
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,30 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo(
"dev",
"开发者",
SettingsPageCategory.Dev,
IconKey = "DeveloperBoard",
SortOrder = 0,
TitleLocalizationKey = "settings.dev.title",
DescriptionLocalizationKey = "settings.dev.description")]
public partial class DevSettingsPage : SettingsPageBase
{
public DevSettingsPage()
: this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
{
}
public DevSettingsPage(DevSettingsPageViewModel viewModel)
{
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
}
public DevSettingsPageViewModel ViewModel { get; }
}

View File

@@ -97,6 +97,12 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId);
}
public void RebuildAndNavigateToDevPage()
{
_pageRegistry.Rebuild();
ReloadPages("dev");
}
public void OpenDrawer(Control content, string? title = null)
{
if (DrawerContentHost is null)
@@ -734,8 +740,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
"Hourglass" => Symbol.Hourglass,
"Alert" => Symbol.Alert, // 铃铛图标
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
"Alert" => Symbol.Alert,
"Bell" => Symbol.Alert,
"DeveloperBoard" => Symbol.DeveloperBoard,
"FolderLink" => Symbol.FolderLink,
"WindowConsole" => Symbol.WindowConsole,
_ => Symbol.Settings
};
}

View File

@@ -454,21 +454,73 @@ begin
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
end;
// Checks whether a .NET 10.x shared framework is installed under the given
// base path by enumerating version sub-directories and looking for one that
// starts with '10.'.
function IsDotNet10RuntimePresent(const BasePath: String): Boolean;
var
FindRec: TFindRec;
begin
Result := False;
if not DirExists(BasePath) then
begin
exit;
end;
if FindFirst(BasePath + '\*', FindRec) then
begin
try
repeat
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and
(Length(FindRec.Name) >= 3) and
(Copy(FindRec.Name, 1, 3) = '10.') then
begin
Result := True;
exit;
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
end;
// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime
// which is sufficient for Avalonia apps) is found on the system.
// We check both Microsoft.WindowsDesktop.App and Microsoft.NETCore.App because
// the runtimeconfig.json may reference either framework depending on the
// publish mode and the app only needs the one it actually references.
function IsDotNetDesktopRuntimeInstalled(): Boolean;
var
RuntimePath: String;
BasePath: String;
begin
Result := False;
RuntimePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
if DirExists(RuntimePath) then
// Check 64-bit Program Files
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
RuntimePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
if DirExists(RuntimePath) then
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
// Check 32-bit Program Files
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Plugins;
public sealed class DevPluginOptions
{
private static readonly string[] DevPluginPathArgs = ["--dev-plugin", "-dp"];
private static readonly string[] DevModeArgs = ["--dev-mode", "-dev"];
private static readonly string[] HotReloadArgs = ["--hot-reload", "-hr"];
private static readonly string EnvDevPluginPath = "LMD_DEV_PLUGIN";
private static readonly string EnvDevMode = "LMD_DEV_MODE";
public static DevPluginOptions Current { get; } = new();
public bool IsDevMode { get; private set; }
public string? DevPluginPath { get; private set; }
public bool EnableHotReload { get; private set; }
public IReadOnlyList<string> DevPluginPaths { get; private set; } = Array.Empty<string>();
private DevPluginOptions() { }
public static DevPluginOptions Parse(string[] args)
{
var options = Current;
options.IsDevMode = TryGetFlag(args, DevModeArgs) ||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal) ||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "true", StringComparison.OrdinalIgnoreCase);
options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ??
Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim();
options.EnableHotReload = TryGetFlag(args, HotReloadArgs);
if (!options.IsDevMode && !string.IsNullOrWhiteSpace(options.DevPluginPath))
{
options.IsDevMode = true;
}
options.DevPluginPaths = ResolveDevPluginPaths(options.DevPluginPath);
if (options.IsDevMode)
{
AppLogger.Info(
"DevPlugin",
$"Developer mode enabled. DevPluginPath='{options.DevPluginPath}'; EnableHotReload={options.EnableHotReload}; ResolvedPaths={options.DevPluginPaths.Count}.");
}
return options;
}
internal void ApplySettingsFromSnapshot(bool isDevMode, string? devPluginPath)
{
if (isDevMode && !IsDevMode)
{
IsDevMode = true;
}
if (!string.IsNullOrWhiteSpace(devPluginPath) && string.IsNullOrWhiteSpace(DevPluginPath))
{
DevPluginPath = devPluginPath;
}
var allPaths = new List<string>(DevPluginPaths);
if (!string.IsNullOrWhiteSpace(devPluginPath))
{
foreach (var path in ResolveDevPluginPaths(devPluginPath))
{
if (!allPaths.Contains(path, StringComparer.OrdinalIgnoreCase))
{
allPaths.Add(path);
}
}
}
DevPluginPaths = allPaths;
}
private static IReadOnlyList<string> ResolveDevPluginPaths(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return Array.Empty<string>();
}
var paths = rawPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var resolved = new List<string>();
foreach (var path in paths)
{
try
{
var fullPath = Path.GetFullPath(path);
if (Directory.Exists(fullPath) || File.Exists(fullPath))
{
resolved.Add(fullPath);
}
else
{
AppLogger.Warn("DevPlugin", $"Developer plugin path '{path}' does not exist. It will be skipped.");
}
}
catch (Exception ex)
{
AppLogger.Warn("DevPlugin", $"Failed to resolve developer plugin path '{path}': {ex.Message}");
}
}
return resolved;
}
private static bool TryGetFlag(string[] args, string[] names)
{
return args.Any(arg => names.Any(name => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)));
}
private static string? TryGetValue(string[] args, string[] names)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (names.Any(name => string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)))
{
return args[i + 1]?.Trim();
}
}
return null;
}
}

View File

@@ -5,7 +5,8 @@ namespace LanMountainDesktop.Services;
public enum PluginCatalogSourceKind
{
Package = 0,
Manifest = 1
Manifest = 1,
DevPlugin = 2
}
public sealed record PluginCatalogEntry(
@@ -16,4 +17,5 @@ public sealed record PluginCatalogEntry(
bool IsLoaded,
string? ErrorMessage,
int SettingsPageCount,
int WidgetCount);
int WidgetCount,
bool IsDevPlugin = false);

View File

@@ -146,7 +146,7 @@ public sealed class PluginLoader
try
{
Directory.CreateDirectory(dataDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory, _options.IsDevMode);
AppLogger.Info(
"PluginLoader",
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
@@ -721,13 +721,23 @@ public sealed class PluginLoader
private static void ValidatePluginRuntimeAssets(
PluginManifest manifest,
string assemblyPath,
string pluginDirectory)
string pluginDirectory,
bool isDevMode)
{
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
if (!File.Exists(depsFilePath))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
if (isDevMode)
{
AppLogger.Warn(
"PluginLoader",
$"Plugin '{manifest.Id}' is missing '{Path.GetFileName(depsFilePath)}'. In developer mode this is allowed, but dependency resolution may fail at runtime.");
}
else
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
}
}
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");
@@ -848,6 +858,8 @@ public sealed class PluginLoader
private sealed class PluginRuntimeContext : IPluginRuntimeContext
{
private readonly PluginAppearanceContext _appearanceContext;
public PluginRuntimeContext(
PluginManifest manifest,
string pluginDirectory,
@@ -859,7 +871,8 @@ public sealed class PluginLoader
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Properties = properties;
Appearance = new PluginAppearanceContext(appearanceSnapshot);
_appearanceContext = new PluginAppearanceContext(appearanceSnapshot);
Appearance = _appearanceContext;
Services = NullServiceProvider.Instance;
}
@@ -898,6 +911,14 @@ public sealed class PluginLoader
{
Services = services ?? throw new ArgumentNullException(nameof(services));
}
/// <summary>
/// 更新外观快照并通知插件。
/// </summary>
internal void UpdateAppearanceSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
{
_appearanceContext.UpdateSnapshot(newSnapshot, changedProperties);
}
}
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable

View File

@@ -19,6 +19,8 @@ public sealed class PluginLoaderOptions
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
public bool IsDevMode { get; init; }
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
typeof(IPlugin).Assembly.GetName().Name!

View File

@@ -85,6 +85,7 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory);
ApplyPendingPluginDeletions();
UnloadInstalledPlugins();
MergeDevSettingsFromSnapshot();
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
var disabledPluginIds = GetDisabledPluginIds();
@@ -108,19 +109,30 @@ public sealed class PluginRuntimeService : IDisposable
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
var isDevPlugin = candidate.SourceKind == PluginCatalogSourceKind.DevPlugin;
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);
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
continue;
if (isDevPlugin)
{
AppLogger.Info(
"DevPlugin",
$"Developer plugin '{candidate.Manifest.Id}' overrides an already-registered plugin from '{candidate.SourcePath}'.");
}
else
{
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);
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
continue;
}
}
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
var isEnabled = isDevPlugin || !disabledPluginIds.Contains(candidate.Manifest.Id);
if (!isEnabled)
{
_catalog.Add(new PluginCatalogEntry(
@@ -172,6 +184,10 @@ public sealed class PluginRuntimeService : IDisposable
PluginsDirectory,
services: _hostServices,
hostProperties),
PluginCatalogSourceKind.DevPlugin => _loader.LoadFromManifest(
candidate.SourcePath,
services: _hostServices,
hostProperties),
_ => _loader.LoadFromManifest(
candidate.SourcePath,
services: _hostServices,
@@ -192,7 +208,8 @@ public sealed class PluginRuntimeService : IDisposable
true,
null,
loadResult.LoadedPlugin.SettingsSections.Count,
loadResult.LoadedPlugin.DesktopComponents.Count));
loadResult.LoadedPlugin.DesktopComponents.Count,
IsDevPlugin: isDevPlugin));
AppLogger.Info(
"PluginRuntime",
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
@@ -208,7 +225,8 @@ public sealed class PluginRuntimeService : IDisposable
false,
loadResult.Error?.Message,
0,
0));
0,
IsDevPlugin: isDevPlugin));
LogPluginFailure("Load", loadResult, treatAsError: true);
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
}
@@ -229,6 +247,14 @@ public sealed class PluginRuntimeService : IDisposable
return false;
}
var catalogEntry = _catalog.FirstOrDefault(entry =>
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
if (catalogEntry.IsDevPlugin && !isEnabled)
{
AppLogger.Warn("DevPlugin", $"Cannot disable developer plugin '{pluginId}'. Developer plugins are always enabled in dev mode.");
return false;
}
var snapshot = LoadAppSettingsSnapshot();
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
@@ -459,12 +485,74 @@ public sealed class PluginRuntimeService : IDisposable
}
}
DiscoverDevPluginCandidates(candidates, failures);
return candidates
.OrderBy(candidate => candidate.SourceKind)
.OrderByDescending(candidate => candidate.SourceKind)
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private void DiscoverDevPluginCandidates(List<PluginCandidate> candidates, List<PluginLoadResult> failures)
{
var devOptions = DevPluginOptions.Current;
if (!devOptions.IsDevMode || devOptions.DevPluginPaths.Count == 0)
{
return;
}
AppLogger.Info("DevPlugin", $"Scanning developer plugin paths. Count={devOptions.DevPluginPaths.Count}.");
foreach (var devPath in devOptions.DevPluginPaths)
{
if (File.Exists(devPath) && string.Equals(Path.GetExtension(devPath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
{
try
{
var manifest = ReadManifestFromPackage(devPath);
candidates.Add(new PluginCandidate(devPath, manifest, PluginCatalogSourceKind.DevPlugin));
AppLogger.Info("DevPlugin", $"Found developer plugin package. PluginId='{manifest.Id}'; Path='{devPath}'.");
}
catch (Exception ex)
{
var failure = PluginLoadResult.Failure(devPath, null, ex);
failures.Add(failure);
AppLogger.Warn("DevPlugin", $"Failed to read developer plugin package '{devPath}'.", ex);
}
continue;
}
if (Directory.Exists(devPath))
{
var manifestPath = Path.Combine(devPath, PluginSdkInfo.ManifestFileName);
if (File.Exists(manifestPath))
{
try
{
var manifest = PluginManifest.Load(manifestPath);
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.DevPlugin));
AppLogger.Info("DevPlugin", $"Found developer plugin manifest. PluginId='{manifest.Id}'; Path='{manifestPath}'.");
}
catch (Exception ex)
{
var failure = PluginLoadResult.Failure(manifestPath, null, ex);
failures.Add(failure);
AppLogger.Warn("DevPlugin", $"Failed to load developer plugin manifest '{manifestPath}'.", ex);
}
}
else
{
AppLogger.Warn("DevPlugin", $"Developer plugin directory '{devPath}' does not contain '{PluginSdkInfo.ManifestFileName}'. Skipping.");
}
continue;
}
AppLogger.Warn("DevPlugin", $"Developer plugin path '{devPath}' is neither a file nor a directory. Skipping.");
}
}
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
@@ -582,7 +670,8 @@ public sealed class PluginRuntimeService : IDisposable
private static PluginLoaderOptions CreateOptions()
{
var options = new PluginLoaderOptions();
var devOptions = DevPluginOptions.Current;
var options = new PluginLoaderOptions { IsDevMode = devOptions.IsDevMode };
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
@@ -614,6 +703,31 @@ public sealed class PluginRuntimeService : IDisposable
}
}
private void MergeDevSettingsFromSnapshot()
{
var devOptions = DevPluginOptions.Current;
try
{
var snapshot = LoadAppSettingsSnapshot();
if (snapshot.IsDevModeEnabled && !devOptions.IsDevMode)
{
devOptions.ApplySettingsFromSnapshot(isDevMode: true, devPluginPath: snapshot.DevPluginPath);
AppLogger.Info("DevPlugin", $"Developer mode enabled via settings. DevPluginPath='{snapshot.DevPluginPath}'.");
}
else if (!string.IsNullOrWhiteSpace(snapshot.DevPluginPath) && string.IsNullOrWhiteSpace(devOptions.DevPluginPath))
{
devOptions.ApplySettingsFromSnapshot(isDevMode: devOptions.IsDevMode, devPluginPath: snapshot.DevPluginPath);
AppLogger.Info("DevPlugin", $"Developer plugin path merged from settings. DevPluginPath='{snapshot.DevPluginPath}'.");
}
}
catch (Exception ex)
{
AppLogger.Warn("DevPlugin", "Failed to merge developer settings from snapshot.", ex);
}
}
private void CollectContributions(LoadedPlugin loadedPlugin)
{
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
@@ -826,6 +940,13 @@ public sealed class PluginRuntimeService : IDisposable
_settingsCatalogService.RemovePluginSections(pluginId);
}
private enum PluginCatalogSourceKind
{
Package = 0,
Manifest = 1,
DevPlugin = 2
}
private sealed record PluginCandidate(
string SourcePath,
PluginManifest Manifest,

View File

@@ -87,7 +87,7 @@ dotnet new install LanMountainDesktop.PluginTemplate
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.1)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)

View File

@@ -39,7 +39,7 @@
### 当前阶段
- 产品版本:`1.0.0`
- Plugin SDK API 基线:`4.0.0`
- Plugin SDK API 基线:`4.0.1`
- 当前重点:持续完善宿主体验、设置页体验、组件能力与插件生态
- 近期需求入口:以 `.trae/specs/` 中的 feature spec 为准
@@ -59,4 +59,4 @@
LanMountainDesktop is a cross-platform desktop enhancement product built with Avalonia UI and .NET 10. It targets students, office users, and customization-focused users who want a programmable desktop surface for information, tools, and plugin-driven extensions.
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.0`.
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.1`.