mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
4 Commits
692ca3de3d
...
v0.8.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5acf5bd7 | ||
|
|
b933f3badf | ||
|
|
76d13ac024 | ||
|
|
99a82d64e3 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
||||
# 更新日志 / Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ public enum SettingsPageCategory
|
||||
PluginCatalog = 35,
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
About = 40,
|
||||
Dev = 50
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
89
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal file
89
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal 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 <path> 或 -dp <path>"
|
||||
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=<path>"
|
||||
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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user