diff --git a/CHANGELOG.md b/CHANGELOG.md index ae65a6b..2f2ca22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # 更新日志 / 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) diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj index 4024a34..8adcb54 100644 --- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj +++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 4.0.0 + 4.0.1 LanMountainDesktop.PluginSdk true LanMountainDesktop diff --git a/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs index 0a41024..8af5b8d 100644 --- a/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs +++ b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs @@ -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"; diff --git a/LanMountainDesktop.PluginSdk/README.md b/LanMountainDesktop.PluginSdk/README.md index 4717217..2198e83 100644 --- a/LanMountainDesktop.PluginSdk/README.md +++ b/LanMountainDesktop.PluginSdk/README.md @@ -14,7 +14,7 @@ Official SDK package for LanMountainDesktop plugins. ```xml - + ``` diff --git a/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs b/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs index 22348d6..1f0d66c 100644 --- a/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs +++ b/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs @@ -9,5 +9,6 @@ public enum SettingsPageCategory PluginCatalog = 35, [Obsolete("Use PluginCatalog instead.")] PluginMarket = 35, - About = 40 + About = 40, + Dev = 50 } diff --git a/LanMountainDesktop.PluginTemplate/content/plugin.json b/LanMountainDesktop.PluginTemplate/content/plugin.json index 5de5e7d..eeabb40 100644 --- a/LanMountainDesktop.PluginTemplate/content/plugin.json +++ b/LanMountainDesktop.PluginTemplate/content/plugin.json @@ -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": [] } diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 120a418..7fb4155 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -154,6 +154,10 @@ public sealed class AppSettingsSnapshot public List DisabledPluginIds { get; set; } = []; + public bool IsDevModeEnabled { get; set; } + + public string? DevPluginPath { get; set; } + #region Study Settings public bool StudyEnabled { get; set; } = true; diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index d1b4f07..5b41667 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -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); diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs index 043d18a..4056516 100644 --- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs +++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs @@ -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(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); diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 941e257..5a46eb9 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -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(SettingsScope.App); + IsDevModeEnabled = snapshot.IsDevModeEnabled; + DevPluginPath = snapshot.DevPluginPath ?? string.Empty; + } + + private void SaveField(string key, T value) + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(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]); + } +} diff --git a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml index fc835ef..e9c5551 100644 --- a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml @@ -36,7 +36,8 @@ + Height="240" + PointerPressed="OnAboutHeroCardPointerPressed"> 3) + { + _heroCardClickCount = 1; + } + else + { + _heroCardClickCount++; + } + + _lastHeroCardClickTime = now; + + var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + var snapshot = settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + + if (snapshot.IsDevModeEnabled) + { + if (_heroCardClickCount >= 3) + { + _heroCardClickCount = 0; + _ = ShowMessageAsync("开发者模式", "开发者模式已启用,无需重复操作。"); + } + + 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(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."); + + _ = ShowMessageAsync("开发者模式", "已启用开发者模式。重新打开设置窗口即可看到开发者选项。"); + + if (HostContext is not null) + { + HostContext.RequestRestart("开发者模式已更改"); + } + } + + private static async Task ShowMessageAsync(string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = "确定" + }; + await dialog.ShowAsync(); + } } diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml new file mode 100644 index 0000000..65ba67c --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs new file mode 100644 index 0000000..fa16ae0 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs @@ -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; } +} diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 6721481..4b426aa 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -734,8 +734,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 }; } diff --git a/LanMountainDesktop/plugins/DevPluginOptions.cs b/LanMountainDesktop/plugins/DevPluginOptions.cs new file mode 100644 index 0000000..3be9a48 --- /dev/null +++ b/LanMountainDesktop/plugins/DevPluginOptions.cs @@ -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 DevPluginPaths { get; private set; } = Array.Empty(); + + 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(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 ResolveDevPluginPaths(string? rawPath) + { + if (string.IsNullOrWhiteSpace(rawPath)) + { + return Array.Empty(); + } + + var paths = rawPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var resolved = new List(); + 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; + } +} diff --git a/LanMountainDesktop/plugins/PluginCatalogEntry.cs b/LanMountainDesktop/plugins/PluginCatalogEntry.cs index 8fe99b5..7929089 100644 --- a/LanMountainDesktop/plugins/PluginCatalogEntry.cs +++ b/LanMountainDesktop/plugins/PluginCatalogEntry.cs @@ -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); diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index 7bc2e25..68a85f2 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -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"); diff --git a/LanMountainDesktop/plugins/PluginLoaderOptions.cs b/LanMountainDesktop/plugins/PluginLoaderOptions.cs index b4f4bf2..5fbb1b9 100644 --- a/LanMountainDesktop/plugins/PluginLoaderOptions.cs +++ b/LanMountainDesktop/plugins/PluginLoaderOptions.cs @@ -19,6 +19,8 @@ public sealed class PluginLoaderOptions public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName; + public bool IsDevMode { get; init; } + public ISet SharedAssemblyNames { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) { typeof(IPlugin).Assembly.GetName().Name! diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index 1be2753..c5cad4e 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -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(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 ?? ""}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? ""}'; 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(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 candidates, List 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 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, diff --git a/README.md b/README.md index c561a1c..fcf9986 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index aea1a7a..be882e7 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.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`.