From cab35f4c22b06783c7f4341f60ab2ffaccfafb40 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 9 Mar 2026 12:27:33 +0800 Subject: [PATCH] 0.5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件系统试验 --- .gitignore | 4 + LanMountainDesktop.PluginSdk/IPlugin.cs | 6 + .../IPluginContext.cs | 24 + .../LanMountainDesktop.PluginSdk.csproj | 14 + LanMountainDesktop.PluginSdk/LoadedPlugin.cs | 74 +++ LanMountainDesktop.PluginSdk/PluginBase.cs | 8 + .../PluginDesktopComponentContext.cs | 66 ++ .../PluginDesktopComponentRegistration.cs | 66 ++ .../PluginDesktopComponentResizeMode.cs | 7 + .../PluginEntranceAttribute.cs | 6 + .../PluginLoadContext.cs | 66 ++ .../PluginLoadResult.cs | 20 + LanMountainDesktop.PluginSdk/PluginLoader.cs | 616 ++++++++++++++++++ .../PluginLoaderOptions.cs | 21 + .../PluginManifest.cs | 107 +++ LanMountainDesktop.PluginSdk/PluginSdkInfo.cs | 12 + .../PluginSettingsPageRegistration.cs | 30 + .../LanMountainDesktop.SamplePlugin.csproj | 29 + .../SamplePlugin.cs | 66 ++ .../SamplePluginRuntimeStatus.cs | 251 +++++++ .../SamplePluginSettingsView.cs | 190 ++++++ .../SamplePluginStatusClockWidget.cs | 227 +++++++ LanMountainDesktop.SamplePlugin/plugin.json | 9 + LanMountainDesktop.sln | 12 + LanMountainDesktop/App.axaml.cs | 18 + .../ComponentSystem/ComponentRegistry.cs | 7 + LanMountainDesktop/LanMountainDesktop.csproj | 4 + LanMountainDesktop/Localization/en-US.json | 29 +- LanMountainDesktop/Localization/zh-CN.json | 29 +- .../Models/AppSettingsSnapshot.cs | 7 + .../DesktopComponentRegistryFactory.cs | 174 +++++ .../Services/PluginCatalogEntry.cs | 19 + .../Services/PluginContributions.cs | 11 + .../Services/PluginRuntimeService.cs | 315 +++++++++ .../DesktopComponentRuntimeRegistry.cs | 84 ++- .../Views/MainWindow.ComponentSystem.cs | 4 +- .../Views/MainWindow.Localization.cs | 16 +- .../Views/MainWindow.PluginSettings.cs | 193 ++++++ .../Views/MainWindow.Settings.cs | 101 ++- LanMountainDesktop/Views/MainWindow.axaml | 2 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 20 +- .../SettingsPages/PluginSettingsPage.axaml | 25 + .../SettingsPages/PluginSettingsPage.axaml.cs | 212 +++++- .../Views/SettingsWindow.Controls.cs | 6 +- .../Views/SettingsWindow.Core.cs | 92 ++- .../Views/SettingsWindow.Localization.cs | 14 +- .../Views/SettingsWindow.PluginSettings.cs | 193 ++++++ LanMountainDesktop/Views/SettingsWindow.axaml | 2 +- .../Views/SettingsWindow.axaml.cs | 5 +- 49 files changed, 3355 insertions(+), 158 deletions(-) create mode 100644 LanMountainDesktop.PluginSdk/IPlugin.cs create mode 100644 LanMountainDesktop.PluginSdk/IPluginContext.cs create mode 100644 LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj create mode 100644 LanMountainDesktop.PluginSdk/LoadedPlugin.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginBase.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginDesktopComponentContext.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginDesktopComponentResizeMode.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginLoadContext.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginLoadResult.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginLoader.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginManifest.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginSdkInfo.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs create mode 100644 LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj create mode 100644 LanMountainDesktop.SamplePlugin/SamplePlugin.cs create mode 100644 LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs create mode 100644 LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs create mode 100644 LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs create mode 100644 LanMountainDesktop.SamplePlugin/plugin.json create mode 100644 LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs create mode 100644 LanMountainDesktop/Services/PluginCatalogEntry.cs create mode 100644 LanMountainDesktop/Services/PluginContributions.cs create mode 100644 LanMountainDesktop/Services/PluginRuntimeService.cs create mode 100644 LanMountainDesktop/Views/MainWindow.PluginSettings.cs create mode 100644 LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs diff --git a/.gitignore b/.gitignore index c923c23..43d057b 100644 --- a/.gitignore +++ b/.gitignore @@ -484,3 +484,7 @@ nul /publish-test /_build_verify /_build_verify_tray +/_build_verify_plugin +/_build_verify_plugin_tabs +/_build_verify_sample_plugin +/_build_verify_sample_plugin_capabilities diff --git a/LanMountainDesktop.PluginSdk/IPlugin.cs b/LanMountainDesktop.PluginSdk/IPlugin.cs new file mode 100644 index 0000000..cb0699d --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPlugin.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IPlugin +{ + void Initialize(IPluginContext context); +} diff --git a/LanMountainDesktop.PluginSdk/IPluginContext.cs b/LanMountainDesktop.PluginSdk/IPluginContext.cs new file mode 100644 index 0000000..be883f3 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginContext.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace LanMountainDesktop.PluginSdk; + +public interface IPluginContext +{ + PluginManifest Manifest { get; } + + string PluginDirectory { get; } + + string DataDirectory { get; } + + IServiceProvider Services { get; } + + IReadOnlyDictionary Properties { get; } + + T? GetService(); + + bool TryGetProperty(string key, out T? value); + + void RegisterSettingsPage(PluginSettingsPageRegistration registration); + + void RegisterDesktopComponent(PluginDesktopComponentRegistration registration); +} diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj new file mode 100644 index 0000000..3e94d17 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + 1.0.0 + + + + + + + diff --git a/LanMountainDesktop.PluginSdk/LoadedPlugin.cs b/LanMountainDesktop.PluginSdk/LoadedPlugin.cs new file mode 100644 index 0000000..4b242a4 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/LoadedPlugin.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using System.Threading; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class LoadedPlugin : IDisposable, IAsyncDisposable +{ + private int _disposed; + + internal LoadedPlugin( + PluginManifest manifest, + string sourcePath, + string assemblyPath, + Assembly assembly, + IPlugin plugin, + IPluginContext context, + IReadOnlyList settingsPages, + IReadOnlyList desktopComponents, + PluginLoadContext loadContext) + { + Manifest = manifest; + SourcePath = sourcePath; + AssemblyPath = assemblyPath; + Assembly = assembly; + Plugin = plugin; + Context = context; + SettingsPages = settingsPages; + DesktopComponents = desktopComponents; + LoadContext = loadContext; + } + + public PluginManifest Manifest { get; } + + public string SourcePath { get; } + + public string AssemblyPath { get; } + + public Assembly Assembly { get; } + + public IPlugin Plugin { get; } + + public IPluginContext Context { get; } + + public IReadOnlyList SettingsPages { get; } + + public IReadOnlyList DesktopComponents { get; } + + public PluginLoadContext LoadContext { get; } + + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Plugin is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (Plugin is IDisposable disposable) + { + disposable.Dispose(); + } + + LoadContext.Unload(); + GC.SuppressFinalize(this); + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginBase.cs b/LanMountainDesktop.PluginSdk/PluginBase.cs new file mode 100644 index 0000000..4b0e1e7 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginBase.cs @@ -0,0 +1,8 @@ +namespace LanMountainDesktop.PluginSdk; + +public abstract class PluginBase : IPlugin +{ + public virtual void Initialize(IPluginContext context) + { + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentContext.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentContext.cs new file mode 100644 index 0000000..26239f9 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentContext.cs @@ -0,0 +1,66 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginDesktopComponentContext +{ + public PluginDesktopComponentContext( + PluginManifest manifest, + string pluginDirectory, + string dataDirectory, + IServiceProvider services, + IReadOnlyDictionary properties, + string componentId, + string? placementId, + double cellSize) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(componentId); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(properties); + + Manifest = manifest; + PluginDirectory = pluginDirectory; + DataDirectory = dataDirectory; + Services = services; + Properties = properties; + ComponentId = componentId.Trim(); + PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim(); + CellSize = Math.Max(1, cellSize); + } + + public PluginManifest Manifest { get; } + + public string PluginDirectory { get; } + + public string DataDirectory { get; } + + public IServiceProvider Services { get; } + + public IReadOnlyDictionary Properties { get; } + + public string ComponentId { get; } + + public string? PlacementId { get; } + + public double CellSize { get; } + + public T? GetService() + { + return (T?)Services.GetService(typeof(T)); + } + + public bool TryGetProperty(string key, out T? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue) + { + value = typedValue; + return true; + } + + value = default; + return false; + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs new file mode 100644 index 0000000..16dd239 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs @@ -0,0 +1,66 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginDesktopComponentRegistration +{ + public PluginDesktopComponentRegistration( + string componentId, + string displayName, + Func controlFactory, + string iconKey = "PuzzlePiece", + string category = "Plugins", + int minWidthCells = 2, + int minHeightCells = 2, + bool allowDesktopPlacement = true, + bool allowStatusBarPlacement = false, + PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional, + string? displayNameLocalizationKey = null, + Func? cornerRadiusResolver = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(componentId); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(iconKey); + ArgumentException.ThrowIfNullOrWhiteSpace(category); + ArgumentNullException.ThrowIfNull(controlFactory); + + ComponentId = componentId.Trim(); + DisplayName = displayName.Trim(); + DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey) + ? null + : displayNameLocalizationKey.Trim(); + ControlFactory = controlFactory; + IconKey = iconKey.Trim(); + Category = category.Trim(); + MinWidthCells = Math.Max(1, minWidthCells); + MinHeightCells = Math.Max(1, minHeightCells); + AllowDesktopPlacement = allowDesktopPlacement; + AllowStatusBarPlacement = allowStatusBarPlacement; + ResizeMode = resizeMode; + CornerRadiusResolver = cornerRadiusResolver; + } + + public string ComponentId { get; } + + public string DisplayName { get; } + + public string? DisplayNameLocalizationKey { get; } + + public Func ControlFactory { get; } + + public string IconKey { get; } + + public string Category { get; } + + public int MinWidthCells { get; } + + public int MinHeightCells { get; } + + public bool AllowDesktopPlacement { get; } + + public bool AllowStatusBarPlacement { get; } + + public PluginDesktopComponentResizeMode ResizeMode { get; } + + public Func? CornerRadiusResolver { get; } +} diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentResizeMode.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentResizeMode.cs new file mode 100644 index 0000000..54954dd --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentResizeMode.cs @@ -0,0 +1,7 @@ +namespace LanMountainDesktop.PluginSdk; + +public enum PluginDesktopComponentResizeMode +{ + Proportional = 0, + Free = 1 +} diff --git a/LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs b/LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs new file mode 100644 index 0000000..a9015eb --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginSdk; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class PluginEntranceAttribute : Attribute +{ +} diff --git a/LanMountainDesktop.PluginSdk/PluginLoadContext.cs b/LanMountainDesktop.PluginSdk/PluginLoadContext.cs new file mode 100644 index 0000000..e48c327 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginLoadContext.cs @@ -0,0 +1,66 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + private readonly HashSet _sharedAssemblyNames; + + public PluginLoadContext(string mainAssemblyPath, IEnumerable? sharedAssemblyNames = null) + : base($"{Path.GetFileNameWithoutExtension(mainAssemblyPath)}_{Guid.NewGuid():N}", isCollectible: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mainAssemblyPath); + + MainAssemblyPath = Path.GetFullPath(mainAssemblyPath); + _resolver = new AssemblyDependencyResolver(MainAssemblyPath); + _sharedAssemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + typeof(IPlugin).Assembly.GetName().Name! + }; + + if (sharedAssemblyNames is null) + { + return; + } + + foreach (var assemblyName in sharedAssemblyNames) + { + if (!string.IsNullOrWhiteSpace(assemblyName)) + { + _sharedAssemblyNames.Add(assemblyName.Trim()); + } + } + } + + public string MainAssemblyPath { get; } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var simpleName = assemblyName.Name; + if (string.IsNullOrWhiteSpace(simpleName)) + { + return null; + } + + if (_sharedAssemblyNames.Contains(simpleName)) + { + return Default.Assemblies.FirstOrDefault( + assembly => string.Equals( + assembly.GetName().Name, + simpleName, + StringComparison.OrdinalIgnoreCase)) + ?? null; + } + + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath); + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return libraryPath is null ? nint.Zero : LoadUnmanagedDllFromPath(libraryPath); + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginLoadResult.cs b/LanMountainDesktop.PluginSdk/PluginLoadResult.cs new file mode 100644 index 0000000..dd0586f --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginLoadResult.cs @@ -0,0 +1,20 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed record PluginLoadResult( + string SourcePath, + PluginManifest? Manifest, + LoadedPlugin? LoadedPlugin, + Exception? Error) +{ + public bool IsSuccess => LoadedPlugin is not null && Error is null; + + public static PluginLoadResult Success(string sourcePath, PluginManifest manifest, LoadedPlugin loadedPlugin) + { + return new PluginLoadResult(sourcePath, manifest, loadedPlugin, null); + } + + public static PluginLoadResult Failure(string sourcePath, PluginManifest? manifest, Exception error) + { + return new PluginLoadResult(sourcePath, manifest, null, error); + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginLoader.cs b/LanMountainDesktop.PluginSdk/PluginLoader.cs new file mode 100644 index 0000000..fa78fd4 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginLoader.cs @@ -0,0 +1,616 @@ +using System.Collections.ObjectModel; +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginLoader +{ + private readonly PluginLoaderOptions _options; + + public PluginLoader(PluginLoaderOptions? options = null) + { + _options = options ?? new PluginLoaderOptions(); + } + + public IReadOnlyList LoadAll( + string pluginsRootDirectory, + IServiceProvider? services = null, + IReadOnlyDictionary? properties = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory); + + if (!Directory.Exists(pluginsRootDirectory)) + { + return Array.Empty(); + } + + var results = new List(); + var candidates = DiscoverCandidates(pluginsRootDirectory, results); + var selectedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var candidate in candidates) + { + if (!selectedPluginIds.Add(candidate.Manifest.Id)) + { + results.Add(PluginLoadResult.Failure( + candidate.SourcePath, + candidate.Manifest, + new InvalidOperationException( + $"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."))); + continue; + } + + results.Add(candidate.SourceKind switch + { + PluginSourceKind.Package => LoadFromPackage( + candidate.SourcePath, + pluginsRootDirectory, + candidate.Manifest, + services, + properties), + _ => LoadFromManifest( + candidate.SourcePath, + candidate.Manifest, + services, + properties) + }); + } + + return results; + } + + public PluginLoadResult LoadFromManifest( + string manifestPath, + IServiceProvider? services = null, + IReadOnlyDictionary? properties = null) + { + PluginManifest? manifest = null; + + try + { + manifest = PluginManifest.Load(manifestPath); + return LoadFromManifest(manifestPath, manifest, services, properties); + } + catch (Exception ex) + { + return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex); + } + } + + public PluginLoadResult LoadFromPackage( + string packagePath, + string pluginsRootDirectory, + IServiceProvider? services = null, + IReadOnlyDictionary? properties = null) + { + PluginManifest? manifest = null; + + try + { + manifest = ReadManifestFromPackage(packagePath); + return LoadFromPackage(packagePath, pluginsRootDirectory, manifest, services, properties); + } + catch (Exception ex) + { + return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex); + } + } + + public PluginLoadResult LoadFromAssembly( + string assemblyPath, + PluginManifest manifest, + IServiceProvider? services = null, + IReadOnlyDictionary? properties = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath); + ArgumentNullException.ThrowIfNull(manifest); + + var fullAssemblyPath = Path.GetFullPath(assemblyPath); + var pluginDirectory = Path.GetDirectoryName(fullAssemblyPath) + ?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{fullAssemblyPath}'."); + var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName); + return LoadCore(fullAssemblyPath, fullAssemblyPath, pluginDirectory, dataDirectory, manifest, services, properties); + } + + private PluginLoadResult LoadCore( + string sourcePath, + string assemblyPath, + string pluginDirectory, + string dataDirectory, + PluginManifest manifest, + IServiceProvider? services, + IReadOnlyDictionary? properties) + { + PluginLoadContext? loadContext = null; + + try + { + loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames); + var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); + var pluginType = ResolvePluginType(assembly); + var plugin = CreatePluginInstance(pluginType); + var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties); + + plugin.Initialize(context); + var settingsPages = context.GetSettingsPagesSnapshot(); + var desktopComponents = context.GetDesktopComponentsSnapshot(); + + var loadedPlugin = new LoadedPlugin( + manifest, + sourcePath, + assemblyPath, + assembly, + plugin, + context, + settingsPages, + desktopComponents, + loadContext); + + return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin); + } + catch (Exception ex) + { + loadContext?.Unload(); + return PluginLoadResult.Failure(sourcePath, manifest, ex); + } + } + + private PluginLoadResult LoadFromManifest( + string manifestPath, + PluginManifest manifest, + IServiceProvider? services, + IReadOnlyDictionary? properties) + { + try + { + var fullManifestPath = Path.GetFullPath(manifestPath); + var assemblyPath = manifest.ResolveEntranceAssemblyPath(fullManifestPath); + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException( + $"Plugin '{manifest.Id}' entrance assembly '{assemblyPath}' was not found.", + assemblyPath); + } + + var pluginDirectory = Path.GetDirectoryName(assemblyPath) + ?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{assemblyPath}'."); + var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName); + return LoadCore(fullManifestPath, assemblyPath, pluginDirectory, dataDirectory, manifest, services, properties); + } + catch (Exception ex) + { + return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex); + } + } + + private PluginLoadResult LoadFromPackage( + string packagePath, + string pluginsRootDirectory, + PluginManifest manifest, + IServiceProvider? services, + IReadOnlyDictionary? properties) + { + try + { + var fullPackagePath = Path.GetFullPath(packagePath); + var extractionDirectory = ExtractPackage(fullPackagePath, pluginsRootDirectory); + var extractedManifestPath = Path.Combine(extractionDirectory, _options.ManifestFileName); + + if (!File.Exists(extractedManifestPath)) + { + throw new FileNotFoundException( + $"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.", + extractedManifestPath); + } + + var extractedManifest = PluginManifest.Load(extractedManifestPath); + if (!string.Equals(extractedManifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Plugin package '{fullPackagePath}' manifest id changed after extraction. Expected '{manifest.Id}', actual '{extractedManifest.Id}'."); + } + + var assemblyPath = extractedManifest.ResolveEntranceAssemblyPath(extractedManifestPath); + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException( + $"Plugin '{extractedManifest.Id}' entrance assembly '{assemblyPath}' was not found after package extraction.", + assemblyPath); + } + + var dataDirectory = GetPackagedDataDirectory(pluginsRootDirectory, extractedManifest); + return LoadCore(fullPackagePath, assemblyPath, extractionDirectory, dataDirectory, extractedManifest, services, properties); + } + catch (Exception ex) + { + return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex); + } + } + + private PluginContext CreateContext( + PluginManifest manifest, + string pluginDirectory, + string dataDirectory, + IServiceProvider? services, + IReadOnlyDictionary? properties) + { + Directory.CreateDirectory(dataDirectory); + + return new PluginContext( + manifest, + pluginDirectory, + dataDirectory, + services ?? NullServiceProvider.Instance, + CreateReadOnlyProperties(properties)); + } + + private IReadOnlyList DiscoverCandidates( + string pluginsRootDirectory, + List preparationFailures) + { + var candidates = new List(); + + foreach (var packagePath in EnumerateCandidatePaths( + pluginsRootDirectory, + "*" + NormalizePackageExtension(_options.PackageFileExtension))) + { + try + { + var manifest = ReadManifestFromPackage(packagePath); + candidates.Add(new PluginCandidate(Path.GetFullPath(packagePath), manifest, PluginSourceKind.Package)); + } + catch (Exception ex) + { + preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(packagePath), null, ex)); + } + } + + foreach (var manifestPath in EnumerateCandidatePaths(pluginsRootDirectory, _options.ManifestFileName)) + { + try + { + var manifest = PluginManifest.Load(manifestPath); + candidates.Add(new PluginCandidate(Path.GetFullPath(manifestPath), manifest, PluginSourceKind.Manifest)); + } + catch (Exception ex) + { + preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(manifestPath), null, ex)); + } + } + + return candidates + .OrderBy(candidate => candidate.SourceKind) + .ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private IEnumerable EnumerateCandidatePaths(string pluginsRootDirectory, string searchPattern) + { + var runtimeRootDirectory = EnsureTrailingSeparator(GetRuntimeRootDirectory(pluginsRootDirectory)); + + return Directory + .EnumerateFiles(pluginsRootDirectory, searchPattern, SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + } + + private PluginManifest ReadManifestFromPackage(string packagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + + var fullPackagePath = Path.GetFullPath(packagePath); + if (!File.Exists(fullPackagePath)) + { + throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath); + } + + using var archive = ZipFile.OpenRead(fullPackagePath); + var manifestEntries = archive.Entries + .Where(entry => + !string.IsNullOrWhiteSpace(entry.Name) && + string.Equals(entry.Name, _options.ManifestFileName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (manifestEntries.Length == 0) + { + throw new InvalidOperationException( + $"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'."); + } + + if (manifestEntries.Length > 1) + { + throw new InvalidOperationException( + $"Plugin package '{fullPackagePath}' contains multiple '{_options.ManifestFileName}' files."); + } + + using var stream = manifestEntries[0].Open(); + return PluginManifest.Load(stream, $"{fullPackagePath}!/{manifestEntries[0].FullName}"); + } + + private string ExtractPackage(string packagePath, string pluginsRootDirectory) + { + var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath); + RecreateDirectory(extractionDirectory); + ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true); + return extractionDirectory; + } + + private string GetPackageExtractionDirectory(string pluginsRootDirectory, string packagePath) + { + var packageName = SanitizeDirectoryName(Path.GetFileNameWithoutExtension(packagePath)); + var packageHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(packagePath)))) + .Substring(0, 12); + + return Path.Combine( + GetRuntimeRootDirectory(pluginsRootDirectory), + _options.ExtractedPackagesDirectoryName, + $"{packageName}_{packageHash}"); + } + + private string GetPackagedDataDirectory(string pluginsRootDirectory, PluginManifest manifest) + { + return Path.Combine( + GetRuntimeRootDirectory(pluginsRootDirectory), + _options.PackagedDataDirectoryName, + SanitizeDirectoryName(manifest.Id)); + } + + private string GetRuntimeRootDirectory(string pluginsRootDirectory) + { + return Path.Combine(Path.GetFullPath(pluginsRootDirectory), _options.RuntimeDirectoryName); + } + + private static void RecreateDirectory(string directoryPath) + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + + Directory.CreateDirectory(directoryPath); + } + + private static string NormalizePackageExtension(string extension) + { + ArgumentException.ThrowIfNullOrWhiteSpace(extension); + return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension; + } + + private static string EnsureTrailingSeparator(string path) + { + var fullPath = Path.GetFullPath(path); + return fullPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? fullPath + : fullPath + Path.DirectorySeparatorChar; + } + + private static string SanitizeDirectoryName(string value) + { + var invalidCharacters = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + + foreach (var ch in value) + { + builder.Append(invalidCharacters.Contains(ch) ? '_' : ch); + } + + return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim(); + } + + private static ReadOnlyDictionary CreateReadOnlyProperties( + IReadOnlyDictionary? properties) + { + if (properties is null || properties.Count == 0) + { + return new ReadOnlyDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in properties) + { + map[pair.Key] = pair.Value; + } + + return new ReadOnlyDictionary(map); + } + + private static Type ResolvePluginType(Assembly assembly) + { + var candidateTypes = GetLoadableTypes(assembly) + .Where(type => + typeof(IPlugin).IsAssignableFrom(type) && + !type.IsAbstract && + !type.IsInterface && + !type.ContainsGenericParameters) + .ToArray(); + + if (candidateTypes.Length == 0) + { + throw new InvalidOperationException( + $"Assembly '{assembly.Location}' does not contain a concrete type implementing '{nameof(IPlugin)}'."); + } + + var attributedTypes = candidateTypes + .Where(type => type.IsDefined(typeof(PluginEntranceAttribute), inherit: false)) + .ToArray(); + + if (attributedTypes.Length == 1) + { + return attributedTypes[0]; + } + + if (attributedTypes.Length > 1) + { + throw new InvalidOperationException( + $"Assembly '{assembly.Location}' contains multiple plugin entrance types. Mark only one type with '{nameof(PluginEntranceAttribute)}'."); + } + + if (candidateTypes.Length == 1) + { + return candidateTypes[0]; + } + + throw new InvalidOperationException( + $"Assembly '{assembly.Location}' contains multiple '{nameof(IPlugin)}' implementations. Mark the intended entrance type with '{nameof(PluginEntranceAttribute)}'."); + } + + private static IPlugin CreatePluginInstance(Type pluginType) + { + if (pluginType.GetConstructor(Type.EmptyTypes) is null) + { + throw new InvalidOperationException( + $"Plugin type '{pluginType.FullName}' must expose a public parameterless constructor."); + } + + if (Activator.CreateInstance(pluginType) is not IPlugin plugin) + { + throw new InvalidOperationException( + $"Failed to create plugin instance of type '{pluginType.FullName}'."); + } + + return plugin; + } + + private static Type[] GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + var loaderMessages = ex.LoaderExceptions + .Where(exception => exception is not null) + .Select(exception => exception!.Message) + .ToArray(); + + var detail = loaderMessages.Length == 0 + ? "No additional loader diagnostics were provided." + : string.Join(Environment.NewLine, loaderMessages); + + throw new InvalidOperationException( + $"Failed to inspect plugin assembly '{assembly.Location}'.{Environment.NewLine}{detail}", + ex); + } + } + + private sealed class PluginContext : IPluginContext + { + private readonly Dictionary _settingsPages = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _desktopComponents = + new(StringComparer.OrdinalIgnoreCase); + + public PluginContext( + PluginManifest manifest, + string pluginDirectory, + string dataDirectory, + IServiceProvider services, + IReadOnlyDictionary properties) + { + Manifest = manifest; + PluginDirectory = pluginDirectory; + DataDirectory = dataDirectory; + Services = services; + Properties = properties; + } + + public PluginManifest Manifest { get; } + + public string PluginDirectory { get; } + + public string DataDirectory { get; } + + public IServiceProvider Services { get; } + + public IReadOnlyDictionary Properties { get; } + + public T? GetService() + { + return (T?)Services.GetService(typeof(T)); + } + + public bool TryGetProperty(string key, out T? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue) + { + value = typedValue; + return true; + } + + value = default; + return false; + } + + public void RegisterSettingsPage(PluginSettingsPageRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + if (!_settingsPages.TryAdd(registration.Id, registration)) + { + throw new InvalidOperationException( + $"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'."); + } + } + + public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + if (!_desktopComponents.TryAdd(registration.ComponentId, registration)) + { + throw new InvalidOperationException( + $"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'."); + } + } + + public IReadOnlyList GetSettingsPagesSnapshot() + { + return _settingsPages.Values + .OrderBy(page => page.SortOrder) + .ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public IReadOnlyList GetDesktopComponentsSnapshot() + { + return _desktopComponents.Values + .OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + + private sealed class NullServiceProvider : IServiceProvider + { + public static NullServiceProvider Instance { get; } = new(); + + private NullServiceProvider() + { + } + + public object? GetService(Type serviceType) + { + return null; + } + } + + private enum PluginSourceKind + { + Package = 0, + Manifest = 1 + } + + private sealed record PluginCandidate( + string SourcePath, + PluginManifest Manifest, + PluginSourceKind SourceKind); +} diff --git a/LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs b/LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs new file mode 100644 index 0000000..b335397 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs @@ -0,0 +1,21 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginLoaderOptions +{ + public string ManifestFileName { get; init; } = PluginSdkInfo.ManifestFileName; + + public string PackageFileExtension { get; init; } = PluginSdkInfo.PackageFileExtension; + + public string DataDirectoryName { get; init; } = PluginSdkInfo.DataDirectoryName; + + public string RuntimeDirectoryName { get; init; } = PluginSdkInfo.RuntimeDirectoryName; + + public string ExtractedPackagesDirectoryName { get; init; } = PluginSdkInfo.ExtractedPackagesDirectoryName; + + public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName; + + public ISet SharedAssemblyNames { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) + { + typeof(IPlugin).Assembly.GetName().Name! + }; +} diff --git a/LanMountainDesktop.PluginSdk/PluginManifest.cs b/LanMountainDesktop.PluginSdk/PluginManifest.cs new file mode 100644 index 0000000..1b18e66 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginManifest.cs @@ -0,0 +1,107 @@ +using System.Text.Json; + +namespace LanMountainDesktop.PluginSdk; + +public sealed record PluginManifest( + string Id, + string Name, + string EntranceAssembly, + string? Description = null, + string? Author = null, + string? Version = null, + string? ApiVersion = null) +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public static PluginManifest Load(string manifestPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + + using var stream = File.OpenRead(manifestPath); + return Load(stream, manifestPath); + } + + public static PluginManifest Load(Stream stream, string sourceName) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + + var manifest = JsonSerializer.Deserialize(stream, SerializerOptions); + if (manifest is null) + { + throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'."); + } + + return manifest.NormalizeAndValidate(sourceName); + } + + public string ResolveEntranceAssemblyPath(string manifestPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + + if (Path.IsPathRooted(EntranceAssembly)) + { + return Path.GetFullPath(EntranceAssembly); + } + + var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)) + ?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'."); + + return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly)); + } + + private PluginManifest NormalizeAndValidate(string manifestPath) + { + var normalized = this with + { + Id = RequireValue(Id, nameof(Id), manifestPath), + Name = RequireValue(Name, nameof(Name), manifestPath), + EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath), + Description = NormalizeOptionalValue(Description), + Author = NormalizeOptionalValue(Author), + Version = NormalizeOptionalValue(Version), + ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion + }; + + if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'."); + } + + if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion)) + { + throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid."); + } + + if (requestedVersion.Major != currentVersion.Major) + { + throw new InvalidOperationException( + $"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'."); + } + + return normalized; + } + + private static string RequireValue(string? value, string propertyName, string manifestPath) + { + var normalized = NormalizeOptionalValue(value); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'."); + } + + return normalized; + } + + private static string? NormalizeOptionalValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs new file mode 100644 index 0000000..6915515 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs @@ -0,0 +1,12 @@ +namespace LanMountainDesktop.PluginSdk; + +public static class PluginSdkInfo +{ + public const string ApiVersion = "1.0.0"; + public const string ManifestFileName = "plugin.json"; + public const string PackageFileExtension = ".laapp"; + public const string DataDirectoryName = "Data"; + public const string RuntimeDirectoryName = ".runtime"; + public const string ExtractedPackagesDirectoryName = "packages"; + public const string PackagedDataDirectoryName = "data"; +} diff --git a/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs b/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs new file mode 100644 index 0000000..8fa6051 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginSettingsPageRegistration +{ + public PluginSettingsPageRegistration( + string id, + string title, + Func contentFactory, + int sortOrder = 0) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentNullException.ThrowIfNull(contentFactory); + + Id = id.Trim(); + Title = title.Trim(); + ContentFactory = contentFactory; + SortOrder = sortOrder; + } + + public string Id { get; } + + public string Title { get; } + + public int SortOrder { get; } + + public Func ContentFactory { get; } +} diff --git a/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj b/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj new file mode 100644 index 0000000..d1bf59d --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + 1.0.0 + true + bin\$(Configuration)\$(TargetFramework)\content\ + false + false + ..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\ + $(PluginPackageOutputDirectory)$(AssemblyName).laapp + ..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\ + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.SamplePlugin/SamplePlugin.cs b/LanMountainDesktop.SamplePlugin/SamplePlugin.cs new file mode 100644 index 0000000..6dcef32 --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/SamplePlugin.cs @@ -0,0 +1,66 @@ +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.SamplePlugin; + +[PluginEntrance] +public sealed class SamplePlugin : PluginBase, IDisposable +{ + private SamplePluginHeartbeatService? _heartbeatService; + + public override void Initialize(IPluginContext context) + { + Directory.CreateDirectory(context.DataDirectory); + + var hostName = context.TryGetProperty("HostApplicationName", out var configuredHostName) && + !string.IsNullOrWhiteSpace(configuredHostName) + ? configuredHostName + : "UnknownHost"; + + var version = context.Manifest.Version ?? "dev"; + SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory); + + var message = + $"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version})."; + + try + { + File.AppendAllText( + Path.Combine(context.DataDirectory, "sample-plugin.log"), + message + Environment.NewLine); + SamplePluginRuntimeStatus.MarkBackendReady( + $"Plugin entry initialized successfully. Host: {hostName}; Version: {version}"); + } + catch (Exception ex) + { + SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}"); + throw; + } + + _heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory); + _heartbeatService.Start(); + + context.RegisterSettingsPage(new PluginSettingsPageRegistration( + "status", + "Plugin Status", + () => new SamplePluginSettingsView(context))); + + context.RegisterDesktopComponent(new PluginDesktopComponentRegistration( + "LanMountainDesktop.SamplePlugin.StatusClock", + "Sample Plugin Status Clock", + widgetContext => new SamplePluginStatusClockWidget(widgetContext), + iconKey: "PuzzlePiece", + category: "Plugins", + minWidthCells: 4, + minHeightCells: 4, + allowDesktopPlacement: true, + allowStatusBarPlacement: false, + resizeMode: PluginDesktopComponentResizeMode.Proportional, + cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34))); + } + + public void Dispose() + { + _heartbeatService?.Dispose(); + _heartbeatService = null; + } +} diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs b/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs new file mode 100644 index 0000000..d8a3c25 --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using System.IO; +using System.Threading; + +namespace LanMountainDesktop.SamplePlugin; + +internal enum SamplePluginHealthState +{ + Healthy, + Pending, + Faulted +} + +internal sealed record SamplePluginStatusEntry( + string Key, + string Title, + SamplePluginHealthState State, + string Summary, + string Detail, + DateTimeOffset UpdatedAt); + +internal static class SamplePluginRuntimeStatus +{ + private static readonly object Gate = new(); + + private static SamplePluginStatusEntry _frontend = CreateEntry( + "frontend", + "Frontend", + SamplePluginHealthState.Pending, + "Pending", + "Frontend surfaces have not been created yet."); + + private static SamplePluginStatusEntry _component = CreateEntry( + "component", + "Component", + SamplePluginHealthState.Pending, + "Pending", + "The 4x4 component has not been created yet."); + + private static SamplePluginStatusEntry _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Pending, + "Pending", + "Plugin initialization has not finished yet."); + + private static SamplePluginStatusEntry _service = CreateEntry( + "service", + "Service", + SamplePluginHealthState.Pending, + "Pending", + "Heartbeat service has not started yet."); + + public static void Reset(string hostName, string version, string dataDirectory) + { + lock (Gate) + { + _frontend = CreateEntry( + "frontend", + "Frontend", + SamplePluginHealthState.Pending, + "Pending", + "Waiting for the settings page or widget surface to render."); + + _component = CreateEntry( + "component", + "Component", + SamplePluginHealthState.Pending, + "Pending", + "The 4x4 component has not been created yet."); + + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Healthy, + "Healthy", + $"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}"); + + _service = CreateEntry( + "service", + "Service", + SamplePluginHealthState.Pending, + "Pending", + "Heartbeat service is starting."); + } + } + + public static void MarkFrontendReady(string detail) + { + lock (Gate) + { + _frontend = CreateEntry( + "frontend", + "Frontend", + SamplePluginHealthState.Healthy, + "Healthy", + detail); + } + } + + public static void MarkComponentCreated(string detail) + { + lock (Gate) + { + _component = CreateEntry( + "component", + "Component", + SamplePluginHealthState.Healthy, + "Created", + detail); + } + } + + public static void MarkBackendReady(string detail) + { + lock (Gate) + { + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Healthy, + "Healthy", + detail); + } + } + + public static void MarkBackendFaulted(string detail) + { + lock (Gate) + { + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Faulted, + "Faulted", + detail); + } + } + + public static void MarkServiceHeartbeat(DateTimeOffset timestamp) + { + lock (Gate) + { + _service = CreateEntry( + "service", + "Service", + SamplePluginHealthState.Healthy, + "Healthy", + $"Heartbeat service is running. Last heartbeat: {timestamp.LocalDateTime:HH:mm:ss}"); + } + } + + public static void MarkServiceFaulted(string detail) + { + lock (Gate) + { + _service = CreateEntry( + "service", + "Service", + SamplePluginHealthState.Faulted, + "Faulted", + detail); + } + } + + public static IReadOnlyList GetSnapshot() + { + lock (Gate) + { + return + [ + _frontend, + _component, + _backend, + _service + ]; + } + } + + private static SamplePluginStatusEntry CreateEntry( + string key, + string title, + SamplePluginHealthState state, + string summary, + string detail) + { + return new SamplePluginStatusEntry( + key, + title, + state, + summary, + detail, + DateTimeOffset.Now); + } +} + +internal sealed class SamplePluginHeartbeatService : IDisposable +{ + private readonly string _heartbeatFilePath; + private readonly Timer _timer; + private int _disposed; + + public SamplePluginHeartbeatService(string dataDirectory) + { + Directory.CreateDirectory(dataDirectory); + _heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt"); + _timer = new Timer(OnTimerTick); + } + + public void Start() + { + PublishHeartbeat(); + _timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _timer.Dispose(); + } + + private void OnTimerTick(object? state) + { + PublishHeartbeat(); + } + + private void PublishHeartbeat() + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + var now = DateTimeOffset.Now; + try + { + File.WriteAllText( + _heartbeatFilePath, + now.ToString("O", CultureInfo.InvariantCulture)); + SamplePluginRuntimeStatus.MarkServiceHeartbeat(now); + } + catch (Exception ex) + { + SamplePluginRuntimeStatus.MarkServiceFaulted($"Heartbeat write failed: {ex.Message}"); + } + } +} diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs b/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs new file mode 100644 index 0000000..8b5c3bf --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs @@ -0,0 +1,190 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.SamplePlugin; + +internal sealed class SamplePluginSettingsView : UserControl +{ + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromSeconds(1) + }; + + private readonly IPluginContext _context; + private readonly TextBlock _summaryTextBlock; + private readonly StackPanel _statusPanel; + + public SamplePluginSettingsView(IPluginContext context) + { + _context = context; + _summaryTextBlock = new TextBlock + { + Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")), + TextWrapping = TextWrapping.Wrap + }; + _statusPanel = new StackPanel + { + Spacing = 10 + }; + + SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully."); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + + Content = new Border + { + Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + [ + new GradientStop(Color.Parse("#1F0B1120"), 0), + new GradientStop(Color.Parse("#260C4A6E"), 1) + ] + }, + BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(18), + Padding = new Thickness(18), + Child = new StackPanel + { + Spacing = 14, + Children = + { + new TextBlock + { + Text = "Sample Plugin Runtime Status", + FontSize = 22, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }, + _summaryTextBlock, + new Border + { + Background = new SolidColorBrush(Color.Parse("#14000000")), + BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14), + Child = _statusPanel + } + } + } + }; + + RefreshStatuses(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + RefreshStatuses(); + _refreshTimer.Start(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _refreshTimer.Stop(); + } + + private void OnRefreshTimerTick(object? sender, EventArgs e) + { + RefreshStatuses(); + } + + private void RefreshStatuses() + { + _summaryTextBlock.Text = + $"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}"; + + _statusPanel.Children.Clear(); + foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot()) + { + var palette = GetPalette(entry.State); + _statusPanel.Children.Add(new Border + { + Background = new SolidColorBrush(palette.Background), + BorderBrush = new SolidColorBrush(palette.Border), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10), + Child = new StackPanel + { + Spacing = 4, + Children = + { + new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 8, + Children = + { + new Border + { + Width = 10, + Height = 10, + CornerRadius = new CornerRadius(999), + Background = new SolidColorBrush(palette.Dot), + VerticalAlignment = VerticalAlignment.Center + }, + new TextBlock + { + Text = entry.Title, + FontSize = 15, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }, + new TextBlock + { + Text = entry.Summary, + Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")), + HorizontalAlignment = HorizontalAlignment.Right + } + } + }, + new TextBlock + { + Text = entry.Detail, + Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}", + Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD")) + } + } + } + }); + + var row = (Grid)((StackPanel)((Border)_statusPanel.Children[^1]).Child!).Children[0]; + Grid.SetColumn(row.Children[1], 1); + Grid.SetColumn(row.Children[2], 2); + } + } + + private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state) + { + return state switch + { + SamplePluginHealthState.Healthy => ( + Color.Parse("#1F115E59"), + Color.Parse("#665EEAD4"), + Color.Parse("#5EEAD4")), + SamplePluginHealthState.Faulted => ( + Color.Parse("#291B1B"), + Color.Parse("#66F87171"), + Color.Parse("#F87171")), + _ => ( + Color.Parse("#2B3A2A0D"), + Color.Parse("#66FBBF24"), + Color.Parse("#FBBF24")) + }; + } +} diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs b/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs new file mode 100644 index 0000000..9523938 --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs @@ -0,0 +1,227 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.SamplePlugin; + +internal sealed class SamplePluginStatusClockWidget : Border +{ + private readonly DispatcherTimer _timer = new() + { + Interval = TimeSpan.FromSeconds(1) + }; + + private readonly PluginDesktopComponentContext _context; + private readonly TextBlock _timeTextBlock; + private readonly TextBlock _titleTextBlock; + private readonly StackPanel _statusPanel; + + public SamplePluginStatusClockWidget(PluginDesktopComponentContext context) + { + _context = context; + _timeTextBlock = new TextBlock + { + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Left + }; + _titleTextBlock = new TextBlock + { + Text = "Plugin Status", + Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")), + HorizontalAlignment = HorizontalAlignment.Left + }; + _statusPanel = new StackPanel + { + Spacing = 8 + }; + + Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + [ + new GradientStop(Color.Parse("#FF07111F"), 0), + new GradientStop(Color.Parse("#FF0C4A6E"), 0.55), + new GradientStop(Color.Parse("#FF0EA5E9"), 1) + ] + }; + BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF")); + BorderThickness = new Thickness(1); + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + Child = new Grid + { + RowDefinitions = new RowDefinitions("Auto,*"), + RowSpacing = 14, + Children = + { + new StackPanel + { + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Left, + Children = + { + _timeTextBlock, + _titleTextBlock + } + }, + new Border + { + Background = new SolidColorBrush(Color.Parse("#1F082F49")), + BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(18), + Padding = new Thickness(12), + Child = _statusPanel + } + } + }; + + Grid.SetRow(((Grid)Child).Children[1], 1); + + _timer.Tick += OnTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + var placementText = string.IsNullOrWhiteSpace(context.PlacementId) + ? "Preview instance created." + : $"Widget created for placement {context.PlacementId}."; + SamplePluginRuntimeStatus.MarkFrontendReady("Widget frontend surface rendered successfully."); + SamplePluginRuntimeStatus.MarkComponentCreated($"{placementText} Baseline footprint: 4x4."); + + RefreshClock(); + RefreshStatusPanel(); + ApplyScale(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + RefreshClock(); + RefreshStatusPanel(); + _timer.Start(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _timer.Stop(); + } + + private void OnTimerTick(object? sender, EventArgs e) + { + RefreshClock(); + RefreshStatusPanel(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyScale(); + RefreshStatusPanel(); + } + + private void RefreshClock() + { + _timeTextBlock.Text = DateTime.Now.ToString("HH:mm:ss"); + } + + private void RefreshStatusPanel() + { + _statusPanel.Children.Clear(); + + var basis = GetLayoutBasis(); + var titleSize = Math.Clamp(basis * 0.072, 11, 16); + var detailSize = Math.Clamp(basis * 0.055, 10, 13); + + foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot()) + { + var palette = GetPalette(entry.State); + var summaryText = $"{entry.Summary} - {entry.UpdatedAt.LocalDateTime:HH:mm:ss}"; + + _statusPanel.Children.Add(new Border + { + Background = new SolidColorBrush(palette.Background), + BorderBrush = new SolidColorBrush(palette.Border), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(10, 8), + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 8, + Children = + { + new Border + { + Width = Math.Clamp(basis * 0.038, 8, 11), + Height = Math.Clamp(basis * 0.038, 8, 11), + CornerRadius = new CornerRadius(999), + Background = new SolidColorBrush(palette.Dot), + VerticalAlignment = VerticalAlignment.Center + }, + new TextBlock + { + Text = entry.Title, + FontSize = titleSize, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White, + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = summaryText, + FontSize = detailSize, + Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")), + HorizontalAlignment = HorizontalAlignment.Right, + TextAlignment = TextAlignment.Right, + VerticalAlignment = VerticalAlignment.Center + } + } + } + }); + + var row = (Grid)((Border)_statusPanel.Children[^1]).Child!; + Grid.SetColumn(row.Children[1], 1); + Grid.SetColumn(row.Children[2], 2); + } + } + + private void ApplyScale() + { + var basis = GetLayoutBasis(); + Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26)); + CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34)); + _timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58); + _titleTextBlock.FontSize = Math.Clamp(basis * 0.07, 12, 18); + } + + private double GetLayoutBasis() + { + var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4; + var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4; + return Math.Max(_context.CellSize * 4, Math.Min(width, height)); + } + + private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state) + { + return state switch + { + SamplePluginHealthState.Healthy => ( + Color.Parse("#1F0F766E"), + Color.Parse("#4D5EEAD4"), + Color.Parse("#5EEAD4")), + SamplePluginHealthState.Faulted => ( + Color.Parse("#29B91C1C"), + Color.Parse("#66F87171"), + Color.Parse("#F87171")), + _ => ( + Color.Parse("#1F7C2D12"), + Color.Parse("#66FDBA74"), + Color.Parse("#FDBA74")) + }; + } +} diff --git a/LanMountainDesktop.SamplePlugin/plugin.json b/LanMountainDesktop.SamplePlugin/plugin.json new file mode 100644 index 0000000..fa139a0 --- /dev/null +++ b/LanMountainDesktop.SamplePlugin/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "LanMountainDesktop.SamplePlugin", + "name": "LanMountain Sample Plugin", + "description": "Example plugin used to validate PluginSdk loading and isolation.", + "author": "LanMountainDesktop", + "version": "1.0.0", + "apiVersion": "1.0.0", + "entranceAssembly": "LanMountainDesktop.SamplePlugin.dll" +} diff --git a/LanMountainDesktop.sln b/LanMountainDesktop.sln index b04ea4e..8b052b3 100644 --- a/LanMountainDesktop.sln +++ b/LanMountainDesktop.sln @@ -5,6 +5,10 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,5 +19,13 @@ Global {00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU {00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU + {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index a20e776..e0cae3d 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -17,6 +17,9 @@ namespace LanMountainDesktop; public partial class App : Application { private SettingsWindow? _traySettingsWindow; + private PluginRuntimeService? _pluginRuntimeService; + + public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; public override void Initialize() { @@ -28,6 +31,7 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { LinuxDesktopEntryInstaller.EnsureInstalled(); + InitializePluginRuntime(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { @@ -172,4 +176,18 @@ public partial class App : Application // Keep startup resilient if user profile folders are unavailable. } } + + private void InitializePluginRuntime() + { + try + { + _pluginRuntimeService?.Dispose(); + _pluginRuntimeService = new PluginRuntimeService(); + _pluginRuntimeService.LoadInstalledPlugins(); + } + catch (Exception ex) + { + Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}"); + } + } } diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 9bc1c40..79bb063 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -385,6 +385,13 @@ public sealed class ComponentRegistry return new ComponentRegistry(merged); } + public ComponentRegistry RegisterComponents(IEnumerable definitions) + { + var merged = _definitions.Values.ToList(); + merged.AddRange(definitions); + return new ComponentRegistry(merged); + } + public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition) { return _definitions.TryGetValue(componentId, out definition!); diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 15b7826..0dda8bf 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -24,6 +24,10 @@ + + + + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 4b9b39c..6b5c827 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -268,9 +268,30 @@ "settings.launcher.restore_button": "Show Again", "settings.plugins.title": "Plugins", "settings.plugins.runtime_header": "Plugin Runtime", - "settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.", - "settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.", - "settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.", + "settings.plugins.runtime_desc": "Review plugin runtime state and load results.", + "settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.", + "settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.", + "settings.plugins.installed_header": "Installed Plugins", + "settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.", + "settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.", + "settings.plugins.empty": "No plugins found.", + "settings.plugins.runtime_unavailable": "Plugin runtime is not available.", + "settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", + "settings.plugins.summary_item_format": "{0} v{1} | {2}", + "settings.plugins.state.enabled": "Enabled", + "settings.plugins.state.enabled_failed": "Enabled / failed to load", + "settings.plugins.state.disabled": "Disabled", + "settings.plugins.state.loaded": "Loaded", + "settings.plugins.state.load_failed": "Load failed", + "settings.plugins.toggle_on": "Enabled", + "settings.plugins.toggle_off": "Disabled", + "settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", + "settings.plugins.toggle_state_enabled": "enabled", + "settings.plugins.toggle_state_disabled": "disabled", + "settings.plugins.source_package": ".laapp package", + "settings.plugins.source_manifest": "Loose manifest", + "settings.plugins.subtitle_format": "{0} | {1} | {2}", + "settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}", "button.component_library": "Edit Desktop", "tooltip.component_library": "Edit Desktop", "component_library.title": "Widgets", @@ -617,3 +638,5 @@ "placement.center": "Center", "placement.tile": "Tile" } + + diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 8ea4e0c..4987de1 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -268,9 +268,30 @@ "settings.launcher.restore_button": "重新显示", "settings.plugins.title": "插件", "settings.plugins.runtime_header": "插件运行时", - "settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。", - "settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。", - "settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。", + "settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。", + "settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。", + "settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。", + "settings.plugins.installed_header": "已安装插件", + "settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。", + "settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。", + "settings.plugins.empty": "未找到插件。", + "settings.plugins.runtime_unavailable": "插件运行时不可用。", + "settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。", + "settings.plugins.summary_item_format": "{0} v{1} | {2}", + "settings.plugins.state.enabled": "已启用", + "settings.plugins.state.enabled_failed": "已启用 / 加载失败", + "settings.plugins.state.disabled": "已禁用", + "settings.plugins.state.loaded": "已加载", + "settings.plugins.state.load_failed": "加载失败", + "settings.plugins.toggle_on": "启用", + "settings.plugins.toggle_off": "禁用", + "settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。", + "settings.plugins.toggle_state_enabled": "启用", + "settings.plugins.toggle_state_disabled": "禁用", + "settings.plugins.source_package": ".laapp 包", + "settings.plugins.source_manifest": "散装清单", + "settings.plugins.subtitle_format": "{0} | {1} | {2}", + "settings.plugins.detail_format": "设置页:{0} | 组件:{1}", "button.component_library": "桌面编辑", "tooltip.component_library": "桌面编辑", "component_library.title": "桌面编辑", @@ -617,3 +638,5 @@ "placement.center": "居中", "placement.tile": "平铺" } + + diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index bd7e94f..8e439da 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot public int SettingsTabIndex { get; set; } = 0; + public string? SettingsTabTag { get; set; } + public string LanguageCode { get; set; } = "zh-CN"; public string? TimeZoneId { get; set; } @@ -70,6 +72,8 @@ public sealed class AppSettingsSnapshot public int StatusBarCustomSpacingPercent { get; set; } = 12; + public List DisabledPluginIds { get; set; } = []; + public AppSettingsSnapshot Clone() { var clone = (AppSettingsSnapshot)MemberwiseClone(); @@ -80,6 +84,9 @@ public sealed class AppSettingsSnapshot clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 } ? new List(PinnedTaskbarActions) : []; + clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 } + ? new List(DisabledPluginIds) + : []; return clone; } diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs new file mode 100644 index 0000000..dbb0686 --- /dev/null +++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.ComponentSystem.Extensions; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Views.Components; + +namespace LanMountainDesktop.Services; + +public static class DesktopComponentRegistryFactory +{ + public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService) + { + var registry = ComponentRegistry + .CreateDefault() + .RegisterExtensions( + JsonComponentExtensionProvider.LoadProvidersFromDirectory( + Path.Combine(AppContext.BaseDirectory, "Extensions", "Components"))); + + var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService); + return pluginDefinitions.Count == 0 + ? registry + : registry.RegisterComponents(pluginDefinitions); + } + + public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry( + ComponentRegistry componentRegistry, + PluginRuntimeService? pluginRuntimeService) + { + var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList(); + var registeredIds = new HashSet( + registrations.Select(registration => registration.ComponentId), + StringComparer.OrdinalIgnoreCase); + + if (pluginRuntimeService is not null) + { + foreach (var contribution in pluginRuntimeService.DesktopComponents) + { + var registration = contribution.Registration; + if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _)) + { + continue; + } + + if (!registeredIds.Add(registration.ComponentId)) + { + Debug.WriteLine( + $"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists."); + continue; + } + + registrations.Add(new DesktopComponentRuntimeRegistration( + registration.ComponentId, + registration.DisplayNameLocalizationKey, + factoryContext => CreatePluginControl(contribution, factoryContext), + registration.CornerRadiusResolver)); + } + } + + return new DesktopComponentRuntimeRegistry(componentRegistry, registrations); + } + + private static List GetPluginDefinitions( + ComponentRegistry baseRegistry, + PluginRuntimeService? pluginRuntimeService) + { + var definitions = new List(); + if (pluginRuntimeService is null) + { + return definitions; + } + + var knownIds = new HashSet( + baseRegistry.GetAll().Select(definition => definition.Id), + StringComparer.OrdinalIgnoreCase); + + foreach (var contribution in pluginRuntimeService.DesktopComponents) + { + var registration = contribution.Registration; + if (!knownIds.Add(registration.ComponentId)) + { + Debug.WriteLine( + $"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists."); + continue; + } + + definitions.Add(new DesktopComponentDefinition( + registration.ComponentId, + registration.DisplayName, + registration.IconKey, + registration.Category, + registration.MinWidthCells, + registration.MinHeightCells, + registration.AllowStatusBarPlacement, + registration.AllowDesktopPlacement, + registration.ResizeMode == PluginDesktopComponentResizeMode.Free + ? DesktopComponentResizeMode.Free + : DesktopComponentResizeMode.Proportional)); + } + + return definitions; + } + + private static Control CreatePluginControl( + PluginDesktopComponentContribution contribution, + DesktopComponentControlFactoryContext context) + { + try + { + var pluginContext = new PluginDesktopComponentContext( + contribution.Plugin.Manifest, + contribution.Plugin.Context.PluginDirectory, + contribution.Plugin.Context.DataDirectory, + contribution.Plugin.Context.Services, + contribution.Plugin.Context.Properties, + contribution.Registration.ComponentId, + context.PlacementId, + context.CellSize); + + return contribution.Registration.ControlFactory(pluginContext); + } + catch (Exception ex) + { + Debug.WriteLine( + $"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}"); + return CreatePluginErrorControl(contribution, ex); + } + } + + private static Control CreatePluginErrorControl( + PluginDesktopComponentContribution contribution, + Exception exception) + { + return new Border + { + Background = new SolidColorBrush(Color.Parse("#332B0F16")), + BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(12), + Child = new StackPanel + { + Spacing = 6, + Children = + { + new TextBlock + { + Text = contribution.Registration.DisplayName, + FontSize = 14, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.", + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = exception.Message, + TextWrapping = TextWrapping.Wrap + } + } + } + }; + } +} diff --git a/LanMountainDesktop/Services/PluginCatalogEntry.cs b/LanMountainDesktop/Services/PluginCatalogEntry.cs new file mode 100644 index 0000000..8fe99b5 --- /dev/null +++ b/LanMountainDesktop/Services/PluginCatalogEntry.cs @@ -0,0 +1,19 @@ +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +public enum PluginCatalogSourceKind +{ + Package = 0, + Manifest = 1 +} + +public sealed record PluginCatalogEntry( + PluginManifest Manifest, + string SourcePath, + bool IsPackage, + bool IsEnabled, + bool IsLoaded, + string? ErrorMessage, + int SettingsPageCount, + int WidgetCount); diff --git a/LanMountainDesktop/Services/PluginContributions.cs b/LanMountainDesktop/Services/PluginContributions.cs new file mode 100644 index 0000000..343a4e9 --- /dev/null +++ b/LanMountainDesktop/Services/PluginContributions.cs @@ -0,0 +1,11 @@ +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +public sealed record PluginSettingsPageContribution( + LoadedPlugin Plugin, + PluginSettingsPageRegistration Registration); + +public sealed record PluginDesktopComponentContribution( + LoadedPlugin Plugin, + PluginDesktopComponentRegistration Registration); diff --git a/LanMountainDesktop/Services/PluginRuntimeService.cs b/LanMountainDesktop/Services/PluginRuntimeService.cs new file mode 100644 index 0000000..c109003 --- /dev/null +++ b/LanMountainDesktop/Services/PluginRuntimeService.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +public sealed class PluginRuntimeService : IDisposable +{ + private readonly PluginLoader _loader; + private readonly AppSettingsService _appSettingsService = new(); + private readonly List _loadedPlugins = []; + private readonly List _loadResults = []; + private readonly List _catalog = []; + private readonly List _settingsPages = []; + private readonly List _desktopComponents = []; + + public PluginRuntimeService() + { + PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); + _loader = new PluginLoader(CreateOptions()); + } + + public string PluginsDirectory { get; } + + public IReadOnlyList LoadedPlugins => _loadedPlugins; + + public IReadOnlyList LoadResults => _loadResults; + + public IReadOnlyList Catalog => _catalog; + + public IReadOnlyList SettingsPages => _settingsPages; + + public IReadOnlyList DesktopComponents => _desktopComponents; + + public void LoadInstalledPlugins() + { + Directory.CreateDirectory(PluginsDirectory); + UnloadInstalledPlugins(); + + var disabledPluginIds = GetDisabledPluginIds(); + var hostProperties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["HostApplicationName"] = "LanMountainDesktop", + ["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(), + ["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion + }; + + var discoveryFailures = new List(); + var candidates = DiscoverCandidates(discoveryFailures); + _loadResults.AddRange(discoveryFailures); + + var selectedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var candidate in candidates) + { + if (!selectedPluginIds.Add(candidate.Manifest.Id)) + { + var duplicateFailure = PluginLoadResult.Failure( + candidate.SourcePath, + candidate.Manifest, + new InvalidOperationException( + $"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected.")); + _loadResults.Add(duplicateFailure); + continue; + } + + var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id); + if (!isEnabled) + { + _catalog.Add(new PluginCatalogEntry( + candidate.Manifest, + candidate.SourcePath, + candidate.SourceKind == PluginCatalogSourceKind.Package, + false, + false, + null, + 0, + 0)); + continue; + } + + var loadResult = candidate.SourceKind switch + { + PluginCatalogSourceKind.Package => _loader.LoadFromPackage( + candidate.SourcePath, + PluginsDirectory, + services: null, + hostProperties), + _ => _loader.LoadFromManifest( + candidate.SourcePath, + services: null, + hostProperties) + }; + + _loadResults.Add(loadResult); + + if (loadResult.IsSuccess && loadResult.LoadedPlugin is not null) + { + _loadedPlugins.Add(loadResult.LoadedPlugin); + CollectContributions(loadResult.LoadedPlugin); + _catalog.Add(new PluginCatalogEntry( + loadResult.LoadedPlugin.Manifest, + loadResult.SourcePath, + candidate.SourceKind == PluginCatalogSourceKind.Package, + true, + true, + null, + loadResult.LoadedPlugin.SettingsPages.Count, + loadResult.LoadedPlugin.DesktopComponents.Count)); + Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'."); + continue; + } + + _catalog.Add(new PluginCatalogEntry( + candidate.Manifest, + candidate.SourcePath, + candidate.SourceKind == PluginCatalogSourceKind.Package, + true, + false, + loadResult.Error?.Message, + 0, + 0)); + Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}"); + } + + if (_catalog.Count == 0 && discoveryFailures.Count == 0) + { + Debug.WriteLine($"[PluginRuntime] No .laapp packages or loose plugin manifests found under '{PluginsDirectory}'."); + } + } + + public bool SetPluginEnabled(string pluginId, bool isEnabled) + { + if (string.IsNullOrWhiteSpace(pluginId)) + { + return false; + } + + var snapshot = _appSettingsService.Load(); + var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 } + ? new HashSet(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var changed = isEnabled + ? disabledPluginIds.Remove(pluginId) + : disabledPluginIds.Add(pluginId); + + if (!changed) + { + return false; + } + + snapshot.DisabledPluginIds = disabledPluginIds + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToList(); + _appSettingsService.Save(snapshot); + + for (var i = 0; i < _catalog.Count; i++) + { + if (string.Equals(_catalog[i].Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)) + { + _catalog[i] = _catalog[i] with { IsEnabled = isEnabled }; + } + } + + return true; + } + + public void Dispose() + { + UnloadInstalledPlugins(); + } + + private void UnloadInstalledPlugins() + { + for (var i = _loadedPlugins.Count - 1; i >= 0; i--) + { + _loadedPlugins[i].Dispose(); + } + + _loadedPlugins.Clear(); + _loadResults.Clear(); + _catalog.Clear(); + _settingsPages.Clear(); + _desktopComponents.Clear(); + } + + private HashSet GetDisabledPluginIds() + { + var snapshot = _appSettingsService.Load(); + return snapshot.DisabledPluginIds is { Count: > 0 } + ? new HashSet(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + } + + private IReadOnlyList DiscoverCandidates(List failures) + { + var candidates = new List(); + + foreach (var packagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}")) + { + try + { + var manifest = ReadManifestFromPackage(packagePath); + candidates.Add(new PluginCandidate(packagePath, manifest, PluginCatalogSourceKind.Package)); + } + catch (Exception ex) + { + failures.Add(PluginLoadResult.Failure(packagePath, null, ex)); + } + } + + foreach (var manifestPath in EnumerateCandidatePaths("plugin.json")) + { + try + { + var manifest = PluginManifest.Load(manifestPath); + candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.Manifest)); + } + catch (Exception ex) + { + failures.Add(PluginLoadResult.Failure(manifestPath, null, ex)); + } + } + + return candidates + .OrderBy(candidate => candidate.SourceKind) + .ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private IEnumerable EnumerateCandidatePaths(string searchPattern) + { + var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime")); + + return Directory + .EnumerateFiles(PluginsDirectory, searchPattern, SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + } + + private static PluginManifest ReadManifestFromPackage(string packagePath) + { + using var archive = ZipFile.OpenRead(packagePath); + var entries = archive.Entries + .Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (entries.Length == 0) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'."); + } + + if (entries.Length > 1) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files."); + } + + using var stream = entries[0].Open(); + return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); + } + + private static string EnsureTrailingSeparator(string path) + { + return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? path + : path + Path.DirectorySeparatorChar; + } + + private static PluginLoaderOptions CreateOptions() + { + var options = new PluginLoaderOptions(); + AddSharedAssembly(options, typeof(App).Assembly); + AddSharedAssembly(options, typeof(Application).Assembly); + AddSharedAssembly(options, typeof(Control).Assembly); + AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly); + return options; + } + + private static void AddSharedAssembly(PluginLoaderOptions options, Assembly assembly) + { + var assemblyName = assembly.GetName().Name; + if (!string.IsNullOrWhiteSpace(assemblyName)) + { + options.SharedAssemblyNames.Add(assemblyName); + } + } + + private void CollectContributions(LoadedPlugin loadedPlugin) + { + foreach (var settingsPage in loadedPlugin.SettingsPages) + { + _settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage)); + } + + foreach (var desktopComponent in loadedPlugin.DesktopComponents) + { + _desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent)); + } + } + + private sealed record PluginCandidate( + string SourcePath, + PluginManifest Manifest, + PluginCatalogSourceKind SourceKind); +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 9b16d08..2c3cf62 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -7,24 +7,65 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public sealed record DesktopComponentRuntimeRegistration( - string ComponentId, - string DisplayNameLocalizationKey, - Func ControlFactory, - Func? CornerRadiusResolver = null); +public sealed record DesktopComponentControlFactoryContext( + DesktopComponentDefinition Definition, + double CellSize, + TimeZoneService TimeZoneService, + IWeatherInfoService WeatherInfoService, + IRecommendationInfoService RecommendationInfoService, + ICalculatorDataService CalculatorDataService, + IComponentInstanceSettingsStore ComponentSettingsStore, + string? PlacementId = null); + +public sealed class DesktopComponentRuntimeRegistration +{ + public DesktopComponentRuntimeRegistration( + string componentId, + string? displayNameLocalizationKey, + Func controlFactory, + Func? cornerRadiusResolver = null) + : this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver) + { + } + + public DesktopComponentRuntimeRegistration( + string componentId, + string? displayNameLocalizationKey, + Func controlFactory, + Func? cornerRadiusResolver = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(componentId); + ArgumentNullException.ThrowIfNull(controlFactory); + + ComponentId = componentId.Trim(); + DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey) + ? null + : displayNameLocalizationKey.Trim(); + ControlFactory = controlFactory; + CornerRadiusResolver = cornerRadiusResolver; + } + + public string ComponentId { get; } + + public string? DisplayNameLocalizationKey { get; } + + public Func ControlFactory { get; } + + public Func? CornerRadiusResolver { get; } +} public sealed class DesktopComponentRuntimeDescriptor { private static readonly Func DefaultCornerRadiusResolver = cellSize => Math.Clamp(cellSize * 0.22, 8, 18); - private readonly Func _controlFactory; + private readonly Func _controlFactory; private readonly Func _cornerRadiusResolver; internal DesktopComponentRuntimeDescriptor( DesktopComponentDefinition definition, - string displayNameLocalizationKey, - Func controlFactory, + string? displayNameLocalizationKey, + Func controlFactory, Func? cornerRadiusResolver) { Definition = definition; @@ -35,7 +76,7 @@ public sealed class DesktopComponentRuntimeDescriptor public DesktopComponentDefinition Definition { get; } - public string DisplayNameLocalizationKey { get; } + public string? DisplayNameLocalizationKey { get; } public Control CreateControl( double cellSize, @@ -46,7 +87,15 @@ public sealed class DesktopComponentRuntimeDescriptor IComponentInstanceSettingsStore componentSettingsStore, string? placementId = null) { - var control = _controlFactory(); + var control = _controlFactory(new DesktopComponentControlFactoryContext( + Definition, + cellSize, + timeZoneService, + weatherInfoService, + recommendationInfoService, + calculatorDataService, + componentSettingsStore, + placementId)); var runtimeContext = new DesktopComponentRuntimeContext( Definition.Id, placementId, @@ -133,12 +182,10 @@ public sealed class DesktopComponentRuntimeRegistry StringComparer.OrdinalIgnoreCase); } - public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry) + public static IReadOnlyList GetDefaultRegistrations() { - return new DesktopComponentRuntimeRegistry( - componentRegistry, - new[] - { + return + [ new DesktopComponentRuntimeRegistration( BuiltInComponentIds.Date, "component.date", @@ -319,7 +366,12 @@ public sealed class DesktopComponentRuntimeRegistry "component.holiday_calendar", () => new HolidayCalendarWidget(), cellSize => Math.Clamp(cellSize * 0.32, 12, 28)) - }); + ]; + } + + public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry) + { + return new DesktopComponentRuntimeRegistry(componentRegistry, GetDefaultRegistrations()); } public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor) diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index a57bf0f..66d40a2 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -3216,7 +3216,9 @@ public partial class MainWindow private string GetLocalizedComponentDisplayName(DesktopComponentRuntimeDescriptor descriptor) { - return L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName); + return string.IsNullOrWhiteSpace(descriptor.DisplayNameLocalizationKey) + ? descriptor.Definition.DisplayName + : L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName); } private void OnComponentLibraryComponentPreviewPointerPressed(object? sender, PointerPressedEventArgs e) diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 2c5a7c6..d3a0582 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -280,13 +280,22 @@ public partial class MainWindow PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime"); PluginSystemSettingsExpander.Description = L( "settings.plugins.runtime_desc", - "Manage plugin loading and backend isolation."); + "Review plugin runtime state and load results."); PluginSystemDescriptionTextBlock.Text = L( "settings.plugins.runtime_hint", - "This page will host installed plugin management, permission review, and sandboxed backend runtime controls."); + "This page shows discovery status, load results, and runtime diagnostics for installed plugins."); PluginSystemStatusTextBlock.Text = L( "settings.plugins.runtime_status", - "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel."); + "Plugin runtime status will appear here after plugin discovery completes."); + InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins"); + InstalledPluginsSettingsExpander.Description = L( + "settings.plugins.installed_desc", + "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages."); + PluginRestartHintTextBlock.Text = L( + "settings.plugins.restart_hint", + "Plugin enable state changes take effect after restarting the app."); + PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found."); + PluginSettingsPanel.RefreshFromRuntime(); SettingsNavAboutItem.Content = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); @@ -428,3 +437,4 @@ public partial class MainWindow : _weatherLocationKey); } } + diff --git a/LanMountainDesktop/Views/MainWindow.PluginSettings.cs b/LanMountainDesktop/Views/MainWindow.PluginSettings.cs new file mode 100644 index 0000000..d0a34f3 --- /dev/null +++ b/LanMountainDesktop/Views/MainWindow.PluginSettings.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using FluentAvalonia.UI.Controls; +using FluentIcons.Avalonia.Fluent; +using FluentIcons.Common; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + private readonly Dictionary _pluginSettingsPageHosts = new(StringComparer.OrdinalIgnoreCase); + + private void InitializePluginSettingsNavigation() + { + if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null) + { + return; + } + + var runtime = (Application.Current as App)?.PluginRuntimeService; + var contributions = runtime?.SettingsPages + .OrderBy(contribution => contribution.Registration.SortOrder) + .ThenBy(contribution => contribution.Plugin.Manifest.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(contribution => contribution.Registration.Title, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (contributions is not { Length: > 0 }) + { + return; + } + + var pageCountsByPluginId = contributions + .GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1; + foreach (var contribution in contributions) + { + var tag = BuildPluginSettingsTag(contribution); + var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId); + var navItem = new NavigationViewItem + { + Content = navigationTitle, + Tag = tag, + IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = FluentIcons.Common.Symbol.PuzzlePiece, + IconVariant = FluentIcons.Common.IconVariant.Regular + } + }; + + ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}"); + + SettingsNavView.MenuItems.Insert(insertIndex++, navItem); + + var pageHost = CreatePluginSettingsPageHost(contribution); + pageHost.IsVisible = false; + SettingsContentPagesHost.Children.Add(pageHost); + _pluginSettingsPageHosts[tag] = pageHost; + } + } + + private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution) + { + return $"PluginPage:{contribution.Plugin.Manifest.Id}:{contribution.Registration.Id}"; + } + + private static string BuildPluginSettingsNavigationTitle( + PluginSettingsPageContribution contribution, + IReadOnlyDictionary pageCountsByPluginId) + { + return pageCountsByPluginId.TryGetValue(contribution.Plugin.Manifest.Id, out var pageCount) && pageCount > 1 + ? $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}" + : contribution.Plugin.Manifest.Name; + } + + private Control CreatePluginSettingsPageHost(PluginSettingsPageContribution contribution) + { + Control content; + try + { + content = contribution.Registration.ContentFactory(); + } + catch (Exception ex) + { + content = CreatePluginPageErrorContent(ex); + } + + return new StackPanel + { + Spacing = 16, + Children = + { + new TextBlock + { + Text = contribution.Registration.Title, + FontSize = 24, + FontWeight = FontWeight.SemiBold, + Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush") + }, + new TextBlock + { + Text = contribution.Plugin.Manifest.Name, + Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush") + }, + content + } + }; + } + + private Control CreatePluginPageErrorContent(Exception exception) + { + return new Border + { + Background = new SolidColorBrush(Color.Parse("#332B0F16")), + BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(16), + Child = new TextBlock + { + Text = exception.Message, + TextWrapping = TextWrapping.Wrap + } + }; + } + + private void UpdatePluginSettingsPageVisibility(string? selectedTag) + { + foreach (var pair in _pluginSettingsPageHosts) + { + pair.Value.IsVisible = string.Equals(pair.Key, selectedTag, StringComparison.OrdinalIgnoreCase); + } + } + + private string? GetSelectedSettingsTabTag() + { + return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString(); + } + + private int ResolveSelectedSettingsTabIndex() + { + if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null) + { + return 0; + } + + for (var i = 0; i < SettingsNavView.MenuItems.Count; i++) + { + if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem)) + { + return i; + } + } + + return 0; + } + + private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot) + { + if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0) + { + return; + } + + if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag)) + { + var taggedItem = SettingsNavView.MenuItems + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase)); + if (taggedItem is not null) + { + SettingsNavView.SelectedItem = taggedItem; + return; + } + } + + var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1)); + if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem) + { + SettingsNavView.SelectedItem = navItem; + } + } +} + + + diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 75834d8..270fa9f 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -100,24 +100,7 @@ public partial class MainWindow private int GetSettingsTabIndex() { - if (SettingsNavView?.SelectedItem is FluentAvalonia.UI.Controls.NavigationViewItem item) - { - return item.Tag?.ToString() switch - { - "Wallpaper" => 0, - "Grid" => 1, - "Color" => 2, - "StatusBar" => 3, - "Weather" => 4, - "Region" => 5, - "Update" => 6, - "About" => 7, - "Launcher" => 8, - "Plugins" => 9, - _ => 0 - }; - } - return 0; + return ResolveSelectedSettingsTabIndex(); } private void UpdateSettingsTabContent() @@ -150,6 +133,7 @@ public partial class MainWindow AboutSettingsPanel.IsVisible = tag == "About"; LauncherSettingsPanel.IsVisible = tag == "Launcher"; PluginSettingsPanel.IsVisible = tag == "Plugins"; + UpdatePluginSettingsPageVisibility(tag); if (tag == "Launcher") { @@ -984,10 +968,7 @@ public partial class MainWindow GridSizeNumberBox.Value = _targetShortSideCells; GridSizeSlider.Value = _targetShortSideCells; - if (SettingsNavView.MenuItems.ElementAtOrDefault(Math.Clamp(snapshot.SettingsTabIndex, 0, 9)) is FluentAvalonia.UI.Controls.NavigationViewItem navItem) - { - SettingsNavView.SelectedItem = navItem; - } + RestoreSettingsTabSelection(snapshot); UpdateSettingsTabContent(); WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement); @@ -1036,42 +1017,41 @@ public partial class MainWindow private AppSettingsSnapshot BuildAppSettingsSnapshot() { - return new AppSettingsSnapshot - { - GridShortSideCells = _targetShortSideCells, - GridSpacingPreset = _gridSpacingPreset, - DesktopEdgeInsetPercent = _desktopEdgeInsetPercent, - IsNightMode = _isNightMode, - ThemeColor = _selectedThemeColor.ToString(), - WallpaperPath = _wallpaperPath, - WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()), - SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()), - LanguageCode = _languageCode, - TimeZoneId = _timeZoneService.CurrentTimeZone.Id, - WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode), - WeatherLocationKey = _weatherLocationKey, - WeatherLocationName = _weatherLocationName, - WeatherLatitude = _weatherLatitude, - WeatherLongitude = _weatherLongitude, - WeatherAutoRefreshLocation = _weatherAutoRefreshLocation, - WeatherLocationQuery = BuildLegacyWeatherLocationQuery(), - WeatherExcludedAlerts = _weatherExcludedAlertsRaw, - WeatherIconPackId = _weatherIconPackId, - WeatherNoTlsRequests = _weatherNoTlsRequests, - AutoStartWithWindows = _autoStartWithWindows, - AutoCheckUpdates = _autoCheckUpdates, - IncludePrereleaseUpdates = IncludePrereleaseUpdates, - UpdateChannel = IncludePrereleaseUpdates ? "Preview" : "Stable", - TopStatusComponentIds = _topStatusComponentIds.ToList(), - PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(), - EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, - TaskbarLayoutMode = _taskbarLayoutMode, - ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond", - StatusBarSpacingMode = _statusBarSpacingMode, - StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent - }; + var snapshot = _appSettingsService.Load(); + snapshot.GridShortSideCells = _targetShortSideCells; + snapshot.GridSpacingPreset = _gridSpacingPreset; + snapshot.DesktopEdgeInsetPercent = _desktopEdgeInsetPercent; + snapshot.IsNightMode = _isNightMode; + snapshot.ThemeColor = _selectedThemeColor.ToString(); + snapshot.WallpaperPath = _wallpaperPath; + snapshot.WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()); + snapshot.SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()); + snapshot.SettingsTabTag = GetSelectedSettingsTabTag(); + snapshot.LanguageCode = _languageCode; + snapshot.TimeZoneId = _timeZoneService.CurrentTimeZone.Id; + snapshot.WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode); + snapshot.WeatherLocationKey = _weatherLocationKey; + snapshot.WeatherLocationName = _weatherLocationName; + snapshot.WeatherLatitude = _weatherLatitude; + snapshot.WeatherLongitude = _weatherLongitude; + snapshot.WeatherAutoRefreshLocation = _weatherAutoRefreshLocation; + snapshot.WeatherLocationQuery = BuildLegacyWeatherLocationQuery(); + snapshot.WeatherExcludedAlerts = _weatherExcludedAlertsRaw; + snapshot.WeatherIconPackId = _weatherIconPackId; + snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests; + snapshot.AutoStartWithWindows = _autoStartWithWindows; + snapshot.AutoCheckUpdates = _autoCheckUpdates; + snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates; + snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable; + snapshot.TopStatusComponentIds = _topStatusComponentIds.ToList(); + snapshot.PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(); + snapshot.EnableDynamicTaskbarActions = _enableDynamicTaskbarActions; + snapshot.TaskbarLayoutMode = _taskbarLayoutMode; + snapshot.ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond"; + snapshot.StatusBarSpacingMode = _statusBarSpacingMode; + snapshot.StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent; + return snapshot; } - private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot() { return new DesktopLayoutSettingsSnapshot @@ -1081,7 +1061,6 @@ public partial class MainWindow DesktopComponentPlacements = _desktopComponentPlacements.ToList() }; } - private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot() { return new LauncherSettingsSnapshot @@ -2678,4 +2657,10 @@ public partial class MainWindow internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl("PluginSystemSettingsExpander")!; internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; + internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; + internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; + internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; } + + + diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index ea4cf94..6113da1 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -104,7 +104,7 @@ ClipToBounds="True" BorderThickness="0" PointerWheelChanged="OnDesktopPagesPointerWheelChanged"> - + diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 3073d1b..78b14b5 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -19,7 +19,6 @@ using Avalonia.Styling; using Avalonia.Threading; using FluentAvalonia.Styling; using LanMountainDesktop.ComponentSystem; -using LanMountainDesktop.ComponentSystem.Extensions; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; @@ -98,11 +97,7 @@ public partial class MainWindow : Window private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService(); private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); - private readonly ComponentRegistry _componentRegistry = ComponentRegistry - .CreateDefault() - .RegisterExtensions( - JsonComponentExtensionProvider.LoadProvidersFromDirectory( - Path.Combine(AppContext.BaseDirectory, "Extensions", "Components"))); + private readonly ComponentRegistry _componentRegistry; private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry; private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; private readonly HashSet _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase); @@ -182,8 +177,14 @@ public partial class MainWindow : Window public MainWindow() { + var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; + _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); + InitializeComponent(); - _componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry); + InitializePluginSettingsNavigation(); + _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( + _componentRegistry, + pluginRuntimeService); _fluentAvaloniaTheme = Application.Current?.Styles.OfType().FirstOrDefault(); AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved; LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved; @@ -300,10 +301,7 @@ public partial class MainWindow : Window GridSizeSlider.ValueChanged += OnGridSizeSliderChanged; GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged; - if (SettingsNavView.MenuItems.ElementAtOrDefault(Math.Clamp(snapshot.SettingsTabIndex, 0, 9)) is FluentAvalonia.UI.Controls.NavigationViewItem navItem) - { - SettingsNavView.SelectedItem = navItem; - } + RestoreSettingsTabSelection(snapshot); UpdateSettingsTabContent(); WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement); diff --git a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml index 6508da6..a499838 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml @@ -35,6 +35,31 @@ TextWrapping="Wrap" Text="Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel." /> + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs index ea42ab1..cbe7315 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs @@ -1,12 +1,222 @@ +using System; +using System.Globalization; +using System.Linq; +using Avalonia; using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using Avalonia.Layout; +using Avalonia.Media; +using FluentAvalonia.UI.Controls; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.SettingsPages; public partial class PluginSettingsPage : UserControl { + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + public PluginSettingsPage() { InitializeComponent(); + AttachedToVisualTree += (_, _) => RefreshFromRuntime(); + } + + public void RefreshFromRuntime() + { + var runtime = (Application.Current as App)?.PluginRuntimeService; + if (runtime is null) + { + PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available."); + PluginRuntimeSummaryPanel.Children.Clear(); + PluginCatalogItemsHost.Children.Clear(); + PluginRestartHintTextBlock.IsVisible = false; + return; + } + + BuildRuntimeSummary(runtime); + BuildPluginCatalog(runtime); + } + + private void BuildRuntimeSummary(PluginRuntimeService runtime) + { + var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray(); + var enabledCount = runtime.Catalog.Count(entry => entry.IsEnabled); + PluginSystemStatusTextBlock.Text = F( + "settings.plugins.summary_format", + "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", + runtime.Catalog.Count, + enabledCount, + runtime.LoadedPlugins.Count, + runtime.SettingsPages.Count, + runtime.DesktopComponents.Count, + failures.Length); + + PluginRuntimeSummaryPanel.Children.Clear(); + foreach (var plugin in runtime.Catalog.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)) + { + var status = plugin.IsEnabled + ? plugin.IsLoaded + ? L("settings.plugins.state.enabled", "Enabled") + : L("settings.plugins.state.enabled_failed", "Enabled / failed to load") + : L("settings.plugins.state.disabled", "Disabled"); + PluginRuntimeSummaryPanel.Children.Add(CreateSummaryLine( + F( + "settings.plugins.summary_item_format", + "{0} v{1} | {2}", + plugin.Manifest.Name, + plugin.Manifest.Version ?? "dev", + status))); + } + } + + private void BuildPluginCatalog(PluginRuntimeService runtime) + { + PluginCatalogItemsHost.Children.Clear(); + + var plugins = runtime.Catalog + .OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + PluginCatalogEmptyTextBlock.IsVisible = plugins.Count == 0; + PluginRestartHintTextBlock.IsVisible = plugins.Count > 0; + + foreach (var plugin in plugins) + { + PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin)); + } + } + + private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry) + { + var title = new TextBlock + { + Text = entry.Manifest.Name, + FontSize = 16, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }; + + var subtitle = new TextBlock + { + Text = BuildPluginSubtitle(entry), + Foreground = PluginSystemDescriptionTextBlock.Foreground, + TextWrapping = TextWrapping.Wrap + }; + + var enabledToggle = new ToggleSwitch + { + IsChecked = entry.IsEnabled, + OnContent = L("settings.plugins.toggle_on", "Enabled"), + OffContent = L("settings.plugins.toggle_off", "Disabled"), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center + }; + + enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true); + enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false); + + var header = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + ColumnSpacing = 12, + Children = + { + new StackPanel + { + Spacing = 4, + Children = { title, subtitle } + }, + enabledToggle + } + }; + Grid.SetColumn(enabledToggle, 1); + + var details = new TextBlock + { + Text = BuildPluginDetails(entry), + Foreground = PluginSystemDescriptionTextBlock.Foreground, + TextWrapping = TextWrapping.Wrap + }; + + return new Border + { + Background = new SolidColorBrush(Color.Parse("#14000000")), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(14), + Child = new StackPanel + { + Spacing = 10, + Children = { header, details } + } + }; + } + + private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled) + { + runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled); + BuildRuntimeSummary(runtime); + BuildPluginCatalog(runtime); + PluginSystemStatusTextBlock.Text = F( + "settings.plugins.toggle_result_format", + "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", + entry.Manifest.Name, + isEnabled + ? L("settings.plugins.toggle_state_enabled", "enabled") + : L("settings.plugins.toggle_state_disabled", "disabled")); + } + + private string BuildPluginSubtitle(PluginCatalogEntry entry) + { + var source = entry.IsPackage + ? L("settings.plugins.source_package", ".laapp package") + : L("settings.plugins.source_manifest", "Loose manifest"); + var state = entry.IsEnabled + ? entry.IsLoaded + ? L("settings.plugins.state.loaded", "Loaded") + : L("settings.plugins.state.load_failed", "Load failed") + : L("settings.plugins.state.disabled", "Disabled"); + return F( + "settings.plugins.subtitle_format", + "{0} | {1} | {2}", + state, + source, + entry.Manifest.Id); + } + + private string BuildPluginDetails(PluginCatalogEntry entry) + { + var detail = F( + "settings.plugins.detail_format", + "Settings pages: {0} | Widgets: {1}", + entry.SettingsPageCount, + entry.WidgetCount); + return string.IsNullOrWhiteSpace(entry.ErrorMessage) + ? detail + : detail + Environment.NewLine + entry.ErrorMessage; + } + + private TextBlock CreateSummaryLine(string text) + { + return new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + Foreground = PluginSystemDescriptionTextBlock.Foreground + }; + } + + private string L(string key, string fallback) + { + var snapshot = _appSettingsService.Load(); + return _localizationService.GetString(snapshot.LanguageCode, key, fallback); + } + + private string F(string key, string fallback, params object[] args) + { + return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args); } } + + + + diff --git a/LanMountainDesktop/Views/SettingsWindow.Controls.cs b/LanMountainDesktop/Views/SettingsWindow.Controls.cs index d7b9448..8d97aa9 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Controls.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Controls.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Media; using Avalonia.Media.Imaging; using LibVLCSharp.Avalonia; @@ -206,4 +206,8 @@ public partial class SettingsWindow internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl("PluginSystemSettingsExpander")!; internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; + internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; + internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; + internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; } + diff --git a/LanMountainDesktop/Views/SettingsWindow.Core.cs b/LanMountainDesktop/Views/SettingsWindow.Core.cs index 041c64f..39cdb50 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Core.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Core.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -54,25 +54,7 @@ public partial class SettingsWindow private int GetSettingsTabIndex() { - if (SettingsNavView?.SelectedItem is FluentAvalonia.UI.Controls.NavigationViewItem item) - { - return item.Tag?.ToString() switch - { - "Wallpaper" => 0, - "Grid" => 1, - "Color" => 2, - "StatusBar" => 3, - "Weather" => 4, - "Region" => 5, - "Update" => 6, - "About" => 7, - "Launcher" => 8, - "Plugins" => 9, - _ => 0 - }; - } - - return 0; + return ResolveSelectedSettingsTabIndex(); } private void UpdateSettingsTabContent() @@ -95,6 +77,7 @@ public partial class SettingsWindow AboutSettingsPanel.IsVisible = tag == "About"; LauncherSettingsPanel.IsVisible = tag == "Launcher"; PluginSettingsPanel.IsVisible = tag == "Plugins"; + UpdatePluginSettingsPageVisibility(tag); if (tag == "Launcher") { @@ -122,40 +105,40 @@ public partial class SettingsWindow private AppSettingsSnapshot BuildAppSettingsSnapshot() { - return new AppSettingsSnapshot - { - GridShortSideCells = _targetShortSideCells, - GridSpacingPreset = _gridSpacingPreset, - DesktopEdgeInsetPercent = _desktopEdgeInsetPercent, - IsNightMode = _isNightMode, - ThemeColor = _selectedThemeColor.ToString(), - WallpaperPath = _wallpaperPath, - WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()), - SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()), - LanguageCode = _languageCode, - TimeZoneId = _timeZoneService.CurrentTimeZone.Id, - WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode), - WeatherLocationKey = _weatherLocationKey, - WeatherLocationName = _weatherLocationName, - WeatherLatitude = _weatherLatitude, - WeatherLongitude = _weatherLongitude, - WeatherAutoRefreshLocation = _weatherAutoRefreshLocation, - WeatherLocationQuery = BuildLegacyWeatherLocationQuery(), - WeatherExcludedAlerts = _weatherExcludedAlertsRaw, - WeatherIconPackId = _weatherIconPackId, - WeatherNoTlsRequests = _weatherNoTlsRequests, - AutoStartWithWindows = _autoStartWithWindows, - AutoCheckUpdates = _autoCheckUpdates, - IncludePrereleaseUpdates = IncludePrereleaseUpdates, - UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable, - TopStatusComponentIds = _topStatusComponentIds.ToList(), - PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(), - EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, - TaskbarLayoutMode = _taskbarLayoutMode, - ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond", - StatusBarSpacingMode = _statusBarSpacingMode, - StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent - }; + var snapshot = _appSettingsService.Load(); + snapshot.GridShortSideCells = _targetShortSideCells; + snapshot.GridSpacingPreset = _gridSpacingPreset; + snapshot.DesktopEdgeInsetPercent = _desktopEdgeInsetPercent; + snapshot.IsNightMode = _isNightMode; + snapshot.ThemeColor = _selectedThemeColor.ToString(); + snapshot.WallpaperPath = _wallpaperPath; + snapshot.WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()); + snapshot.SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()); + snapshot.SettingsTabTag = GetSelectedSettingsTabTag(); + snapshot.LanguageCode = _languageCode; + snapshot.TimeZoneId = _timeZoneService.CurrentTimeZone.Id; + snapshot.WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode); + snapshot.WeatherLocationKey = _weatherLocationKey; + snapshot.WeatherLocationName = _weatherLocationName; + snapshot.WeatherLatitude = _weatherLatitude; + snapshot.WeatherLongitude = _weatherLongitude; + snapshot.WeatherAutoRefreshLocation = _weatherAutoRefreshLocation; + snapshot.WeatherLocationQuery = BuildLegacyWeatherLocationQuery(); + snapshot.WeatherExcludedAlerts = _weatherExcludedAlertsRaw; + snapshot.WeatherIconPackId = _weatherIconPackId; + snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests; + snapshot.AutoStartWithWindows = _autoStartWithWindows; + snapshot.AutoCheckUpdates = _autoCheckUpdates; + snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates; + snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable; + snapshot.TopStatusComponentIds = _topStatusComponentIds.ToList(); + snapshot.PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(); + snapshot.EnableDynamicTaskbarActions = _enableDynamicTaskbarActions; + snapshot.TaskbarLayoutMode = _taskbarLayoutMode; + snapshot.ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond"; + snapshot.StatusBarSpacingMode = _statusBarSpacingMode; + snapshot.StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent; + return snapshot; } private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot() @@ -514,3 +497,4 @@ public partial class SettingsWindow return Brushes.Transparent; } } + diff --git a/LanMountainDesktop/Views/SettingsWindow.Localization.cs b/LanMountainDesktop/Views/SettingsWindow.Localization.cs index 51e4535..02722e7 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Localization.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Localization.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Avalonia.Controls; @@ -111,9 +111,14 @@ public partial class SettingsWindow PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins"); PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime"); - PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Manage plugin loading and backend isolation."); - PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page will host installed plugin management, permission review, and sandboxed backend runtime controls."); - PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel."); + PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results."); + PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins."); + PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes."); + InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins"); + InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages."); + PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app."); + PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found."); + PluginSettingsPanel.RefreshFromRuntime(); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText()); @@ -183,3 +188,4 @@ public partial class SettingsWindow PersistSettings(); } } + diff --git a/LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs b/LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs new file mode 100644 index 0000000..9faa3e1 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using FluentAvalonia.UI.Controls; +using FluentIcons.Avalonia.Fluent; +using FluentIcons.Common; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views; + +public partial class SettingsWindow +{ + private readonly Dictionary _pluginSettingsPageHosts = new(StringComparer.OrdinalIgnoreCase); + + private void InitializePluginSettingsNavigation() + { + if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null) + { + return; + } + + var runtime = (Application.Current as App)?.PluginRuntimeService; + var contributions = runtime?.SettingsPages + .OrderBy(contribution => contribution.Registration.SortOrder) + .ThenBy(contribution => contribution.Plugin.Manifest.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(contribution => contribution.Registration.Title, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (contributions is not { Length: > 0 }) + { + return; + } + + var pageCountsByPluginId = contributions + .GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1; + foreach (var contribution in contributions) + { + var tag = BuildPluginSettingsTag(contribution); + var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId); + var navItem = new NavigationViewItem + { + Content = navigationTitle, + Tag = tag, + IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = FluentIcons.Common.Symbol.PuzzlePiece, + IconVariant = FluentIcons.Common.IconVariant.Regular + } + }; + + ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}"); + + SettingsNavView.MenuItems.Insert(insertIndex++, navItem); + + var pageHost = CreatePluginSettingsPageHost(contribution); + pageHost.IsVisible = false; + SettingsContentPagesHost.Children.Add(pageHost); + _pluginSettingsPageHosts[tag] = pageHost; + } + } + + private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution) + { + return $"PluginPage:{contribution.Plugin.Manifest.Id}:{contribution.Registration.Id}"; + } + + private static string BuildPluginSettingsNavigationTitle( + PluginSettingsPageContribution contribution, + IReadOnlyDictionary pageCountsByPluginId) + { + return pageCountsByPluginId.TryGetValue(contribution.Plugin.Manifest.Id, out var pageCount) && pageCount > 1 + ? $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}" + : contribution.Plugin.Manifest.Name; + } + + private Control CreatePluginSettingsPageHost(PluginSettingsPageContribution contribution) + { + Control content; + try + { + content = contribution.Registration.ContentFactory(); + } + catch (Exception ex) + { + content = CreatePluginPageErrorContent(ex); + } + + return new StackPanel + { + Spacing = 16, + Children = + { + new TextBlock + { + Text = contribution.Registration.Title, + FontSize = 24, + FontWeight = FontWeight.SemiBold, + Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush") + }, + new TextBlock + { + Text = contribution.Plugin.Manifest.Name, + Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush") + }, + content + } + }; + } + + private static Control CreatePluginPageErrorContent(Exception exception) + { + return new Border + { + Background = new SolidColorBrush(Color.Parse("#332B0F16")), + BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(16), + Child = new TextBlock + { + Text = exception.Message, + TextWrapping = TextWrapping.Wrap + } + }; + } + + private void UpdatePluginSettingsPageVisibility(string? selectedTag) + { + foreach (var pair in _pluginSettingsPageHosts) + { + pair.Value.IsVisible = string.Equals(pair.Key, selectedTag, StringComparison.OrdinalIgnoreCase); + } + } + + private string? GetSelectedSettingsTabTag() + { + return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString(); + } + + private int ResolveSelectedSettingsTabIndex() + { + if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null) + { + return 0; + } + + for (var i = 0; i < SettingsNavView.MenuItems.Count; i++) + { + if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem)) + { + return i; + } + } + + return 0; + } + + private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot) + { + if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0) + { + return; + } + + if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag)) + { + var taggedItem = SettingsNavView.MenuItems + .OfType() + .FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase)); + if (taggedItem is not null) + { + SettingsNavView.SelectedItem = taggedItem; + return; + } + } + + var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1)); + if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem) + { + SettingsNavView.SelectedItem = navItem; + } + } +} + + + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index 454228e..f148556 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -141,7 +141,7 @@ Padding="0,0,16,0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 912e9c3..c48027f 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -93,7 +93,7 @@ public partial class SettingsWindow : Window private readonly WindowsStartupService _windowsStartupService = new(); private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService(); - private readonly ComponentRegistry _componentRegistry = ComponentRegistry.CreateDefault(); + private readonly ComponentRegistry _componentRegistry; private readonly WindowsStartMenuService _windowsStartMenuService = new(); private readonly LinuxDesktopEntryService _linuxDesktopEntryService = new(); private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; @@ -158,7 +158,9 @@ public partial class SettingsWindow : Window public SettingsWindow() { + _componentRegistry = DesktopComponentRegistryFactory.Create((Application.Current as App)?.PluginRuntimeService); InitializeComponent(); + InitializePluginSettingsNavigation(); _fluentAvaloniaTheme = Application.Current?.Styles.OfType().FirstOrDefault(); RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default; HookEvents(); @@ -278,6 +280,7 @@ public partial class SettingsWindow : Window WindowTitleTextBlock.Text = L("settings.title", "Settings"); WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings"); _defaultDesktopBackground = DesktopWallpaperLayer.Background; + RestoreSettingsTabSelection(snapshot); UpdateSettingsTabContent(); UpdateWallpaperDisplay(); UpdateWallpaperPreviewLayout();