From 103b215e35571fdd6f23ff502d2f7b63972859e3 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 9 Mar 2026 14:14:50 +0800 Subject: [PATCH] 0.5.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端服务支持 --- .gitignore | 3 + .../IPluginContext.cs | 3 + .../IPluginMessageBus.cs | 8 + LanMountainDesktop.PluginSdk/LoadedPlugin.cs | 9 + LanMountainDesktop.PluginSdk/PluginLoader.cs | 316 +++++++++++- .../SamplePlugin.cs | 56 +- .../SamplePluginRuntimeStatus.cs | 478 +++++++++++++----- .../SamplePluginSettingsView.cs | 293 ++++++++--- .../SamplePluginStatusClockWidget.cs | 145 ++++-- .../Services/PluginRuntimeService.cs | 19 +- LanMountainDesktop/Views/MainWindow.axaml | 4 +- 11 files changed, 1058 insertions(+), 276 deletions(-) create mode 100644 LanMountainDesktop.PluginSdk/IPluginMessageBus.cs diff --git a/.gitignore b/.gitignore index 43d057b..4a36ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -488,3 +488,6 @@ nul /_build_verify_plugin_tabs /_build_verify_sample_plugin /_build_verify_sample_plugin_capabilities +/_build_verify_plugin_page_host +/_build_verify_plugin_services +/_build_obj diff --git a/LanMountainDesktop.PluginSdk/IPluginContext.cs b/LanMountainDesktop.PluginSdk/IPluginContext.cs index be883f3..bdbe62d 100644 --- a/LanMountainDesktop.PluginSdk/IPluginContext.cs +++ b/LanMountainDesktop.PluginSdk/IPluginContext.cs @@ -18,6 +18,9 @@ public interface IPluginContext bool TryGetProperty(string key, out T? value); + void RegisterService(TService service) + where TService : class; + void RegisterSettingsPage(PluginSettingsPageRegistration registration); void RegisterDesktopComponent(PluginDesktopComponentRegistration registration); diff --git a/LanMountainDesktop.PluginSdk/IPluginMessageBus.cs b/LanMountainDesktop.PluginSdk/IPluginMessageBus.cs new file mode 100644 index 0000000..11f8fbd --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginMessageBus.cs @@ -0,0 +1,8 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IPluginMessageBus +{ + IDisposable Subscribe(Action handler); + + void Publish(TMessage message); +} diff --git a/LanMountainDesktop.PluginSdk/LoadedPlugin.cs b/LanMountainDesktop.PluginSdk/LoadedPlugin.cs index 4b242a4..5bff05e 100644 --- a/LanMountainDesktop.PluginSdk/LoadedPlugin.cs +++ b/LanMountainDesktop.PluginSdk/LoadedPlugin.cs @@ -68,6 +68,15 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable disposable.Dispose(); } + if (Context is IAsyncDisposable asyncContext) + { + await asyncContext.DisposeAsync(); + } + else if (Context is IDisposable disposableContext) + { + disposableContext.Dispose(); + } + LoadContext.Unload(); GC.SuppressFinalize(this); } diff --git a/LanMountainDesktop.PluginSdk/PluginLoader.cs b/LanMountainDesktop.PluginSdk/PluginLoader.cs index fa78fd4..b664efe 100644 --- a/LanMountainDesktop.PluginSdk/PluginLoader.cs +++ b/LanMountainDesktop.PluginSdk/PluginLoader.cs @@ -125,14 +125,16 @@ public sealed class PluginLoader IReadOnlyDictionary? properties) { PluginLoadContext? loadContext = null; + IPlugin? plugin = null; + PluginContext? context = 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 = CreatePluginInstance(pluginType); + context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties); plugin.Initialize(context); var settingsPages = context.GetSettingsPagesSnapshot(); @@ -153,6 +155,8 @@ public sealed class PluginLoader } catch (Exception ex) { + DisposeInstance(plugin); + DisposeInstance(context); loadContext?.Unload(); return PluginLoadResult.Failure(sourcePath, manifest, ex); } @@ -477,6 +481,33 @@ public sealed class PluginLoader return plugin; } + private static void DisposeInstance(object? instance) + { + if (instance is null) + { + return; + } + + try + { + if (instance is IAsyncDisposable asyncDisposable) + { + asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return; + } + + if (instance is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch (Exception disposeError) + { + System.Diagnostics.Debug.WriteLine( + $"[PluginLoader] Disposal of '{instance.GetType().FullName}' failed: {disposeError}"); + } + } + private static Type[] GetLoadableTypes(Assembly assembly) { try @@ -500,12 +531,17 @@ public sealed class PluginLoader } } - private sealed class PluginContext : IPluginContext + private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable { private readonly Dictionary _settingsPages = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _desktopComponents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _registeredServices = []; + private readonly List _serviceRegistrationOrder = []; + private readonly object _serviceGate = new(); + private readonly IServiceProvider _hostServices; + private int _disposed; public PluginContext( PluginManifest manifest, @@ -517,8 +553,12 @@ public sealed class PluginLoader Manifest = manifest; PluginDirectory = pluginDirectory; DataDirectory = dataDirectory; - Services = services; + _hostServices = services; + Services = new PluginCompositeServiceProvider(this); Properties = properties; + + RegisterBuiltInService(this); + RegisterBuiltInService(new PluginMessageBus()); } public PluginManifest Manifest { get; } @@ -550,9 +590,16 @@ public sealed class PluginLoader return false; } + public void RegisterService(TService service) + where TService : class + { + RegisterServiceCore(typeof(TService), service, allowOverride: false); + } + public void RegisterSettingsPage(PluginSettingsPageRegistration registration) { ArgumentNullException.ThrowIfNull(registration); + ThrowIfDisposed(); if (!_settingsPages.TryAdd(registration.Id, registration)) { @@ -564,6 +611,7 @@ public sealed class PluginLoader public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration) { ArgumentNullException.ThrowIfNull(registration); + ThrowIfDisposed(); if (!_desktopComponents.TryAdd(registration.ComponentId, registration)) { @@ -574,6 +622,7 @@ public sealed class PluginLoader public IReadOnlyList GetSettingsPagesSnapshot() { + ThrowIfDisposed(); return _settingsPages.Values .OrderBy(page => page.SortOrder) .ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase) @@ -582,11 +631,270 @@ public sealed class PluginLoader public IReadOnlyList GetDesktopComponentsSnapshot() { + ThrowIfDisposed(); return _desktopComponents.Values .OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase) .ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray(); } + + internal object? ResolveService(Type serviceType) + { + if (Volatile.Read(ref _disposed) != 0) + { + return null; + } + + if (serviceType == typeof(IServiceProvider)) + { + return Services; + } + + lock (_serviceGate) + { + if (_registeredServices.TryGetValue(serviceType, out var service)) + { + return service; + } + + foreach (var registeredService in _registeredServices.Values) + { + if (serviceType.IsInstanceOfType(registeredService)) + { + return registeredService; + } + } + } + + return _hostServices.GetService(serviceType); + } + + private void RegisterBuiltInService(TService service) + where TService : class + { + RegisterServiceCore(typeof(TService), service, allowOverride: true); + } + + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + object[] services; + lock (_serviceGate) + { + services = _serviceRegistrationOrder.ToArray(); + _registeredServices.Clear(); + _serviceRegistrationOrder.Clear(); + } + + _settingsPages.Clear(); + _desktopComponents.Clear(); + + var disposedServices = new HashSet(ReferenceEqualityComparer.Instance); + for (var i = services.Length - 1; i >= 0; i--) + { + var service = services[i]; + if (ReferenceEquals(service, this) || !disposedServices.Add(service)) + { + continue; + } + + if (service is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (service is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + private void RegisterServiceCore(Type serviceType, object service, bool allowOverride) + { + ArgumentNullException.ThrowIfNull(serviceType); + ArgumentNullException.ThrowIfNull(service); + ThrowIfDisposed(); + + if (!serviceType.IsInstanceOfType(service)) + { + throw new InvalidOperationException( + $"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'."); + } + + lock (_serviceGate) + { + if (!allowOverride && _registeredServices.ContainsKey(serviceType)) + { + throw new InvalidOperationException( + $"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'."); + } + + _registeredServices[serviceType] = service; + _serviceRegistrationOrder.Add(service); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(PluginContext)); + } + } + } + + private sealed class PluginCompositeServiceProvider : IServiceProvider + { + private readonly PluginContext _context; + + public PluginCompositeServiceProvider(PluginContext context) + { + _context = context; + } + + public object? GetService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + return _context.ResolveService(serviceType); + } + } + + private sealed class PluginMessageBus : IPluginMessageBus, IDisposable + { + private readonly Dictionary> _subscriptions = []; + private readonly object _gate = new(); + private int _disposed; + + public IDisposable Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(PluginMessageBus)); + } + + var subscription = new Subscription(this, typeof(TMessage), message => handler((TMessage)message!)); + lock (_gate) + { + if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers)) + { + handlers = []; + _subscriptions[subscription.MessageType] = handlers; + } + + handlers.Add(subscription); + } + + return subscription; + } + + public void Publish(TMessage message) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + Subscription[] handlers; + lock (_gate) + { + if (!_subscriptions.TryGetValue(typeof(TMessage), out var subscriptions) || subscriptions.Count == 0) + { + return; + } + + handlers = subscriptions.ToArray(); + } + + foreach (var handler in handlers) + { + try + { + handler.Invoke(message); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine( + $"[PluginMessageBus] Handler for '{typeof(TMessage).FullName}' failed: {ex}"); + } + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (_gate) + { + _subscriptions.Clear(); + } + } + + private void Unsubscribe(Subscription subscription) + { + lock (_gate) + { + if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers)) + { + return; + } + + handlers.Remove(subscription); + if (handlers.Count == 0) + { + _subscriptions.Remove(subscription.MessageType); + } + } + } + + private sealed class Subscription : IDisposable + { + private readonly PluginMessageBus _owner; + private int _disposed; + + public Subscription(PluginMessageBus owner, Type messageType, Action handler) + { + _owner = owner; + MessageType = messageType; + Handler = handler; + } + + public Type MessageType { get; } + + public Action Handler { get; } + + public void Invoke(object? message) + { + if (_disposed != 0) + { + return; + } + + Handler(message); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _owner.Unsubscribe(this); + } + } } private sealed class NullServiceProvider : IServiceProvider diff --git a/LanMountainDesktop.SamplePlugin/SamplePlugin.cs b/LanMountainDesktop.SamplePlugin/SamplePlugin.cs index 6dcef32..d5f70fe 100644 --- a/LanMountainDesktop.SamplePlugin/SamplePlugin.cs +++ b/LanMountainDesktop.SamplePlugin/SamplePlugin.cs @@ -5,39 +5,49 @@ namespace LanMountainDesktop.SamplePlugin; [PluginEntrance] public sealed class SamplePlugin : PluginBase, IDisposable { - private SamplePluginHeartbeatService? _heartbeatService; + private SamplePluginRuntimeStateService? _stateService; + private SamplePluginClockService? _clockService; public override void Initialize(IPluginContext context) { Directory.CreateDirectory(context.DataDirectory); - var hostName = context.TryGetProperty("HostApplicationName", out var configuredHostName) && - !string.IsNullOrWhiteSpace(configuredHostName) - ? configuredHostName - : "UnknownHost"; + var hostName = GetHostProperty(context, "HostApplicationName", "UnknownHost"); + var hostVersion = GetHostProperty(context, "HostVersion", "UnknownVersion"); + var sdkApiVersion = GetHostProperty(context, "PluginSdkApiVersion", "UnknownApiVersion"); + var messageBus = context.GetService() + ?? throw new InvalidOperationException("Plugin message bus is not available."); - var version = context.Manifest.Version ?? "dev"; - SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory); + _stateService = new SamplePluginRuntimeStateService( + context.Manifest, + context.PluginDirectory, + context.DataDirectory, + hostName, + hostVersion, + sdkApiVersion, + messageBus); + context.RegisterService(_stateService); - var message = - $"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version})."; + _clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus); + context.RegisterService(_clockService); + _stateService.AttachClockService(_clockService); + + var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log"); + var initMessage = + $"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"})."; try { - File.AppendAllText( - Path.Combine(context.DataDirectory, "sample-plugin.log"), - message + Environment.NewLine); - SamplePluginRuntimeStatus.MarkBackendReady( - $"Plugin entry initialized successfully. Host: {hostName}; Version: {version}"); + File.AppendAllText(logPath, initMessage + Environment.NewLine); + _stateService.MarkBackendReady($"Initialization log written to {logPath}."); } catch (Exception ex) { - SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}"); + _stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}"); throw; } - _heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory); - _heartbeatService.Start(); + _clockService.Start(); context.RegisterSettingsPage(new PluginSettingsPageRegistration( "status", @@ -60,7 +70,15 @@ public sealed class SamplePlugin : PluginBase, IDisposable public void Dispose() { - _heartbeatService?.Dispose(); - _heartbeatService = null; + _clockService?.Dispose(); + _clockService = null; + _stateService = null; + } + + private static string GetHostProperty(IPluginContext context, string key, string fallback) + { + return context.TryGetProperty(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : fallback; } } diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs b/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs index d8a3c25..ea5a0e7 100644 --- a/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs +++ b/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.IO; +using System.Linq; using System.Threading; +using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.SamplePlugin; @@ -19,75 +21,127 @@ internal sealed record SamplePluginStatusEntry( string Detail, DateTimeOffset UpdatedAt); -internal static class SamplePluginRuntimeStatus +internal sealed record SamplePluginCapabilityItem( + string Title, + string Detail); + +internal sealed record SamplePluginRuntimeSnapshot( + PluginManifest Manifest, + string PluginDirectory, + string DataDirectory, + string HostApplicationName, + string HostVersion, + string SdkApiVersion, + IReadOnlyList StatusEntries, + bool HasPlacedComponent, + int PlacedCount, + int PreviewCount, + IReadOnlyList PlacementIds, + string? LastComponentId, + double LastCellSize, + DateTimeOffset? ServiceClockTime); + +internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime); + +internal sealed record SamplePluginStateChangedMessage(string Reason); + +internal sealed record SamplePluginComponentInstance( + string ComponentId, + string? PlacementId, + double CellSize) { - private static readonly object Gate = new(); + public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId); +} - private static SamplePluginStatusEntry _frontend = CreateEntry( - "frontend", - "Frontend", - SamplePluginHealthState.Pending, - "Pending", - "Frontend surfaces have not been created yet."); +internal sealed class SamplePluginRuntimeStateService +{ + private readonly object _gate = new(); + private readonly IPluginMessageBus _messageBus; + private readonly Dictionary _componentInstances = + new(StringComparer.OrdinalIgnoreCase); - private static SamplePluginStatusEntry _component = CreateEntry( - "component", - "Component", - SamplePluginHealthState.Pending, - "Pending", - "The 4x4 component has not been created yet."); + private readonly PluginManifest _manifest; + private readonly string _pluginDirectory; + private readonly string _dataDirectory; + private readonly string _hostApplicationName; + private readonly string _hostVersion; + private readonly string _sdkApiVersion; - private static SamplePluginStatusEntry _backend = CreateEntry( - "backend", - "Backend", - SamplePluginHealthState.Pending, - "Pending", - "Plugin initialization has not finished yet."); + private SamplePluginStatusEntry _frontend; + private SamplePluginStatusEntry _component; + private SamplePluginStatusEntry _backend; + private SamplePluginStatusEntry _service; + private string? _lastComponentId; + private double _lastCellSize; + private DateTimeOffset? _serviceClockTime; - 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) + public SamplePluginRuntimeStateService( + PluginManifest manifest, + string pluginDirectory, + string dataDirectory, + string hostApplicationName, + string hostVersion, + string sdkApiVersion, + IPluginMessageBus messageBus) { - lock (Gate) - { - _frontend = CreateEntry( - "frontend", - "Frontend", - SamplePluginHealthState.Pending, - "Pending", - "Waiting for the settings page or widget surface to render."); + _manifest = manifest; + _pluginDirectory = pluginDirectory; + _dataDirectory = dataDirectory; + _hostApplicationName = hostApplicationName; + _hostVersion = hostVersion; + _sdkApiVersion = sdkApiVersion; + _messageBus = messageBus; - _component = CreateEntry( - "component", - "Component", - SamplePluginHealthState.Pending, - "Pending", - "The 4x4 component has not been created yet."); + _frontend = CreateEntry( + "frontend", + "Frontend", + SamplePluginHealthState.Pending, + "Pending", + "Waiting for a plugin UI surface to connect."); - _backend = CreateEntry( - "backend", - "Backend", - SamplePluginHealthState.Healthy, - "Healthy", - $"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}"); + _component = CreateEntry( + "component", + "Component", + SamplePluginHealthState.Pending, + "Pending", + "No component instance has been created yet."); - _service = CreateEntry( - "service", - "Service", - SamplePluginHealthState.Pending, - "Pending", - "Heartbeat service is starting."); - } + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Pending, + "Pending", + "Plugin initialization is in progress."); + + _service = CreateEntry( + "service", + "Clock Service", + SamplePluginHealthState.Pending, + "Pending", + "Clock service is not attached yet."); } - public static void MarkFrontendReady(string detail) + public void AttachClockService(SamplePluginClockService clockService) { - lock (Gate) + ArgumentNullException.ThrowIfNull(clockService); + + lock (_gate) + { + _serviceClockTime = clockService.CurrentTime; + _service = CreateEntry( + "service", + "Clock Service", + SamplePluginHealthState.Pending, + "Attached", + "Clock service was attached and is waiting for the first tick."); + } + + PublishStateChanged("Clock service attached"); + } + + public void MarkFrontendReady(string detail) + { + lock (_gate) { _frontend = CreateEntry( "frontend", @@ -96,85 +150,220 @@ internal static class SamplePluginRuntimeStatus "Healthy", detail); } + + PublishStateChanged("Frontend updated"); } - public static void MarkComponentCreated(string detail) + public void MarkBackendReady(string detail) { - lock (Gate) + lock (_gate) + { + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Healthy, + "Healthy", + detail); + } + + PublishStateChanged("Backend updated"); + } + + public void MarkBackendFaulted(string detail) + { + lock (_gate) + { + _backend = CreateEntry( + "backend", + "Backend", + SamplePluginHealthState.Faulted, + "Faulted", + detail); + } + + PublishStateChanged("Backend faulted"); + } + + public void MarkClockServiceTick(DateTimeOffset currentTime) + { + lock (_gate) + { + _serviceClockTime = currentTime; + _service = CreateEntry( + "service", + "Clock Service", + SamplePluginHealthState.Healthy, + "Healthy", + $"Clock service is running. Current service time: {currentTime.LocalDateTime:HH:mm:ss}"); + } + + PublishStateChanged("Clock service tick"); + } + + public void MarkClockServiceFaulted(string detail) + { + lock (_gate) + { + _service = CreateEntry( + "service", + "Clock Service", + SamplePluginHealthState.Faulted, + "Faulted", + detail); + } + + PublishStateChanged("Clock service faulted"); + } + + public string RegisterComponentInstance(string componentId, string? placementId, double cellSize) + { + var instanceId = Guid.NewGuid().ToString("N"); + + lock (_gate) + { + _componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize); + _lastComponentId = componentId; + _lastCellSize = cellSize; + UpdateComponentStatusNoLock(); + } + + PublishStateChanged("Component attached"); + return instanceId; + } + + public void UnregisterComponentInstance(string instanceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); + + var removed = false; + lock (_gate) + { + removed = _componentInstances.Remove(instanceId); + if (removed) + { + UpdateComponentStatusNoLock(); + } + } + + if (removed) + { + PublishStateChanged("Component detached"); + } + } + + public SamplePluginRuntimeSnapshot GetSnapshot() + { + lock (_gate) + { + var placementIds = _componentInstances.Values + .Where(instance => instance.IsPlaced) + .Select(instance => instance.PlacementId!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced); + + return new SamplePluginRuntimeSnapshot( + _manifest, + _pluginDirectory, + _dataDirectory, + _hostApplicationName, + _hostVersion, + _sdkApiVersion, + [_frontend, _component, _backend, _service], + placementIds.Length > 0, + placementIds.Length, + previewCount, + placementIds, + _lastComponentId, + _lastCellSize, + _serviceClockTime); + } + } + + public IReadOnlyList GetCapabilities( + IPluginContext context, + bool hasStateService, + bool hasClockService, + bool hasMessageBus) + { + ArgumentNullException.ThrowIfNull(context); + + var propertyNames = context.Properties.Count == 0 + ? "(none)" + : string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)); + + return + [ + new SamplePluginCapabilityItem( + "IPluginContext.Manifest", + $"Readable. Current plugin id: {context.Manifest.Id}; version: {context.Manifest.Version ?? "dev"}."), + new SamplePluginCapabilityItem( + "IPluginContext.PluginDirectory / DataDirectory", + $"Readable. Plugin directory: {context.PluginDirectory}; data directory: {context.DataDirectory}."), + new SamplePluginCapabilityItem( + "IPluginContext.Properties", + $"Readable. Host properties currently exposed: {propertyNames}."), + new SamplePluginCapabilityItem( + "IPluginContext.GetService()", + $"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."), + new SamplePluginCapabilityItem( + "IPluginContext.RegisterService()", + "Callable during plugin initialization. This plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container."), + new SamplePluginCapabilityItem( + "Plugin communication bus", + "This plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces."), + new SamplePluginCapabilityItem( + "PluginDesktopComponentContext", + "Widgets can read ComponentId, PlacementId, CellSize, and call GetService() against the same plugin service container.") + ]; + } + + private void UpdateComponentStatusNoLock() + { + var placementIds = _componentInstances.Values + .Where(instance => instance.IsPlaced) + .Select(instance => instance.PlacementId!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced); + + if (placementIds.Length > 0) { _component = CreateEntry( "component", "Component", SamplePluginHealthState.Healthy, - "Created", - detail); + "Placed", + $"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}"); + return; } - } - public static void MarkBackendReady(string detail) - { - lock (Gate) + if (previewCount > 0) { - _backend = CreateEntry( - "backend", - "Backend", + _component = CreateEntry( + "component", + "Component", SamplePluginHealthState.Healthy, - "Healthy", - detail); + "Preview", + $"Preview instances: {previewCount}; no placed desktop instance is active yet."); + return; } + + _component = CreateEntry( + "component", + "Component", + SamplePluginHealthState.Pending, + "Pending", + "No component instance is active."); } - public static void MarkBackendFaulted(string detail) + private void PublishStateChanged(string reason) { - 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 - ]; - } + _messageBus.Publish(new SamplePluginStateChangedMessage(reason)); } private static SamplePluginStatusEntry CreateEntry( @@ -194,23 +383,42 @@ internal static class SamplePluginRuntimeStatus } } -internal sealed class SamplePluginHeartbeatService : IDisposable +internal sealed class SamplePluginClockService : IDisposable { - private readonly string _heartbeatFilePath; + private readonly object _gate = new(); + private readonly string _clockStateFilePath; + private readonly SamplePluginRuntimeStateService _stateService; + private readonly IPluginMessageBus _messageBus; private readonly Timer _timer; + private DateTimeOffset _currentTime = DateTimeOffset.Now; private int _disposed; - public SamplePluginHeartbeatService(string dataDirectory) + public SamplePluginClockService( + string dataDirectory, + SamplePluginRuntimeStateService stateService, + IPluginMessageBus messageBus) { - Directory.CreateDirectory(dataDirectory); - _heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt"); + _clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt"); + _stateService = stateService; + _messageBus = messageBus; _timer = new Timer(OnTimerTick); } + public DateTimeOffset CurrentTime + { + get + { + lock (_gate) + { + return _currentTime; + } + } + } + public void Start() { - PublishHeartbeat(); - _timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + PublishTick(); + _timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } public void Dispose() @@ -225,10 +433,10 @@ internal sealed class SamplePluginHeartbeatService : IDisposable private void OnTimerTick(object? state) { - PublishHeartbeat(); + PublishTick(); } - private void PublishHeartbeat() + private void PublishTick() { if (Volatile.Read(ref _disposed) != 0) { @@ -236,16 +444,22 @@ internal sealed class SamplePluginHeartbeatService : IDisposable } var now = DateTimeOffset.Now; + lock (_gate) + { + _currentTime = now; + } + try { File.WriteAllText( - _heartbeatFilePath, + _clockStateFilePath, now.ToString("O", CultureInfo.InvariantCulture)); - SamplePluginRuntimeStatus.MarkServiceHeartbeat(now); + _stateService.MarkClockServiceTick(now); + _messageBus.Publish(new SamplePluginClockTickMessage(now)); } catch (Exception ex) { - SamplePluginRuntimeStatus.MarkServiceFaulted($"Heartbeat write failed: {ex.Message}"); + _stateService.MarkClockServiceFaulted($"Clock state write failed: {ex.Message}"); } } } diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs b/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs index 8b5c3bf..75859dd 100644 --- a/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs +++ b/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs @@ -9,31 +9,27 @@ 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; + private readonly SamplePluginRuntimeStateService _stateService; + private readonly SamplePluginClockService _clockService; + private readonly IPluginMessageBus _messageBus; + private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 }; + private readonly StackPanel _capabilityPanel = new() { Spacing = 8 }; + private readonly StackPanel _statusPanel = new() { Spacing = 10 }; + private readonly List _subscriptions = []; public SamplePluginSettingsView(IPluginContext context) { _context = context; - _summaryTextBlock = new TextBlock - { - Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")), - TextWrapping = TextWrapping.Wrap - }; - _statusPanel = new StackPanel - { - Spacing = 10 - }; + _stateService = context.GetService() + ?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available."); + _clockService = context.GetService() + ?? throw new InvalidOperationException("SamplePluginClockService is not available."); + _messageBus = context.GetService() + ?? throw new InvalidOperationException("IPluginMessageBus is not available."); - SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully."); + _stateService.MarkFrontendReady("Settings page is connected to plugin services and communication."); - _refreshTimer.Tick += OnRefreshTimerTick; AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; @@ -60,51 +56,110 @@ internal sealed class SamplePluginSettingsView : UserControl { new TextBlock { - Text = "Sample Plugin Runtime Status", + Text = "Sample Plugin Capability Inspector", 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 - } + CreateSection("Plugin Info", _pluginInfoPanel), + CreateSection("Accessible Capabilities", _capabilityPanel), + CreateSection("Live Runtime Status", _statusPanel) } } }; - RefreshStatuses(); + RefreshView(); } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - RefreshStatuses(); - _refreshTimer.Start(); + SubscribeToPluginBus(); + RefreshView(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - _refreshTimer.Stop(); + foreach (var subscription in _subscriptions) + { + subscription.Dispose(); + } + + _subscriptions.Clear(); } - private void OnRefreshTimerTick(object? sender, EventArgs e) + private void SubscribeToPluginBus() { - RefreshStatuses(); + if (_subscriptions.Count > 0) + { + return; + } + + _subscriptions.Add(_messageBus.Subscribe(_ => + Dispatcher.UIThread.Post(RefreshView))); + + _subscriptions.Add(_messageBus.Subscribe(_ => + Dispatcher.UIThread.Post(RefreshView))); } - private void RefreshStatuses() + private void RefreshView() { - _summaryTextBlock.Text = - $"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}"; + var snapshot = _stateService.GetSnapshot(); + RefreshPluginInfo(snapshot); + RefreshCapabilities(); + RefreshStatuses(snapshot); + } + private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot) + { + _pluginInfoPanel.Children.Clear(); + _pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Name", snapshot.Manifest.Name)); + _pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Id", snapshot.Manifest.Id)); + _pluginInfoPanel.Children.Add(CreateInfoLine("Version", snapshot.Manifest.Version ?? "dev")); + _pluginInfoPanel.Children.Add(CreateInfoLine("Author", snapshot.Manifest.Author ?? "(none)")); + _pluginInfoPanel.Children.Add(CreateInfoLine("Description", snapshot.Manifest.Description ?? "(none)")); + _pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Directory", snapshot.PluginDirectory)); + _pluginInfoPanel.Children.Add(CreateInfoLine("Data Directory", snapshot.DataDirectory)); + _pluginInfoPanel.Children.Add(CreateInfoLine("Host Application", snapshot.HostApplicationName)); + _pluginInfoPanel.Children.Add(CreateInfoLine("Host Version", snapshot.HostVersion)); + _pluginInfoPanel.Children.Add(CreateInfoLine("SDK API Version", snapshot.SdkApiVersion)); + _pluginInfoPanel.Children.Add(CreateInfoLine("State Service Resolved", (_context.GetService() is not null).ToString())); + _pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService() is not null).ToString())); + _pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService() is not null).ToString())); + _pluginInfoPanel.Children.Add(CreateInfoLine("Component Placed", snapshot.HasPlacedComponent ? "Yes" : "No")); + _pluginInfoPanel.Children.Add(CreateInfoLine("Placed Count", snapshot.PlacedCount.ToString())); + _pluginInfoPanel.Children.Add(CreateInfoLine("Preview Count", snapshot.PreviewCount.ToString())); + _pluginInfoPanel.Children.Add(CreateInfoLine( + "Placement Ids", + snapshot.PlacementIds.Count == 0 ? "(none)" : string.Join(", ", snapshot.PlacementIds))); + _pluginInfoPanel.Children.Add(CreateInfoLine("Last Component Id", snapshot.LastComponentId ?? "(none)")); + _pluginInfoPanel.Children.Add(CreateInfoLine( + "Last Cell Size", + snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : "(unknown)")); + _pluginInfoPanel.Children.Add(CreateInfoLine( + "Clock Service Time", + _clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss"))); + } + + private void RefreshCapabilities() + { + var capabilities = _stateService.GetCapabilities( + _context, + _context.GetService() is not null, + _context.GetService() is not null, + _context.GetService() is not null); + + _capabilityPanel.Children.Clear(); + foreach (var capability in capabilities) + { + _capabilityPanel.Children.Add(CreateCapabilityCard(capability)); + } + } + + private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot) + { _statusPanel.Children.Clear(); - foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot()) + + foreach (var entry in snapshot.StatusEntries) { var palette = GetPalette(entry.State); _statusPanel.Children.Add(new Border @@ -119,35 +174,7 @@ internal sealed class SamplePluginSettingsView : UserControl 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 - } - } - }, + CreateStatusHeader(entry, palette), new TextBlock { Text = entry.Detail, @@ -162,13 +189,135 @@ internal sealed class SamplePluginSettingsView : UserControl } } }); - - var row = (Grid)((StackPanel)((Border)_statusPanel.Children[^1]).Child!).Children[0]; - Grid.SetColumn(row.Children[1], 1); - Grid.SetColumn(row.Children[2], 2); } } + private Border CreateSection(string title, Control content) + { + return 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 = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = title, + FontSize = 16, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }, + content + } + } + }; + } + + private Control CreateInfoLine(string label, string value) + { + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("180,*"), + ColumnSpacing = 10 + }; + + var labelText = new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")), + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }; + var valueText = new TextBlock + { + Text = value, + Foreground = Brushes.White, + TextWrapping = TextWrapping.Wrap + }; + + grid.Children.Add(labelText); + grid.Children.Add(valueText); + Grid.SetColumn(valueText, 1); + return grid; + } + + private Control CreateCapabilityCard(SamplePluginCapabilityItem item) + { + return new Border + { + Background = new SolidColorBrush(Color.Parse("#0F082F49")), + BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10), + Child = new StackPanel + { + Spacing = 4, + Children = + { + new TextBlock + { + Text = item.Title, + Foreground = Brushes.White, + FontWeight = FontWeight.SemiBold + }, + new TextBlock + { + Text = item.Detail, + Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")), + TextWrapping = TextWrapping.Wrap + } + } + } + }; + } + + private static Control CreateStatusHeader( + SamplePluginStatusEntry entry, + (Color Background, Color Border, Color Dot) palette) + { + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 8 + }; + + var dot = new Border + { + Width = 10, + Height = 10, + CornerRadius = new CornerRadius(999), + Background = new SolidColorBrush(palette.Dot), + VerticalAlignment = VerticalAlignment.Center + }; + var title = new TextBlock + { + Text = entry.Title, + FontSize = 15, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + }; + var summary = new TextBlock + { + Text = entry.Summary, + Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")), + HorizontalAlignment = HorizontalAlignment.Right + }; + + grid.Children.Add(dot); + grid.Children.Add(title); + grid.Children.Add(summary); + Grid.SetColumn(title, 1); + Grid.SetColumn(summary, 2); + return grid; + } + private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state) { return state switch diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs b/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs index 9523938..76c2b6d 100644 --- a/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs +++ b/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs @@ -9,35 +9,50 @@ namespace LanMountainDesktop.SamplePlugin; internal sealed class SamplePluginStatusClockWidget : Border { - private readonly DispatcherTimer _timer = new() - { - Interval = TimeSpan.FromSeconds(1) - }; - private readonly PluginDesktopComponentContext _context; + private readonly SamplePluginRuntimeStateService _stateService; + private readonly SamplePluginClockService _clockService; + private readonly IPluginMessageBus _messageBus; private readonly TextBlock _timeTextBlock; - private readonly TextBlock _titleTextBlock; + private readonly TextBlock _subtitleTextBlock; private readonly StackPanel _statusPanel; + private readonly Border _statusHost; + private readonly List _subscriptions = []; + private string? _instanceId; public SamplePluginStatusClockWidget(PluginDesktopComponentContext context) { _context = context; + _stateService = context.GetService() + ?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available."); + _clockService = context.GetService() + ?? throw new InvalidOperationException("SamplePluginClockService is not available."); + _messageBus = context.GetService() + ?? throw new InvalidOperationException("IPluginMessageBus is not available."); + _timeTextBlock = new TextBlock { Foreground = Brushes.White, FontWeight = FontWeight.Bold, HorizontalAlignment = HorizontalAlignment.Left }; - _titleTextBlock = new TextBlock + _subtitleTextBlock = new TextBlock { - Text = "Plugin Status", Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")), - HorizontalAlignment = HorizontalAlignment.Left + HorizontalAlignment = HorizontalAlignment.Left, + TextWrapping = TextWrapping.Wrap }; _statusPanel = new StackPanel { Spacing = 8 }; + _statusHost = new Border + { + Background = new SolidColorBrush(Color.Parse("#1F082F49")), + BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")), + BorderThickness = new Thickness(1), + Child = _statusPanel + }; Background = new LinearGradientBrush { @@ -67,55 +82,59 @@ internal sealed class SamplePluginStatusClockWidget : Border Children = { _timeTextBlock, - _titleTextBlock + _subtitleTextBlock } }, - 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 - } + _statusHost } }; 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(); + RefreshClock(_clockService.CurrentTime); + UpdateSubtitle(); RefreshStatusPanel(); ApplyScale(); } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - RefreshClock(); + if (string.IsNullOrWhiteSpace(_instanceId)) + { + _instanceId = _stateService.RegisterComponentInstance( + _context.ComponentId, + _context.PlacementId, + _context.CellSize); + } + + _stateService.MarkFrontendReady("Widget surface is connected to plugin services and communication."); + SubscribeToPluginBus(); + + RefreshClock(_clockService.CurrentTime); + UpdateSubtitle(); RefreshStatusPanel(); - _timer.Start(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { - _timer.Stop(); - } + foreach (var subscription in _subscriptions) + { + subscription.Dispose(); + } - private void OnTimerTick(object? sender, EventArgs e) - { - RefreshClock(); - RefreshStatusPanel(); + _subscriptions.Clear(); + + if (string.IsNullOrWhiteSpace(_instanceId)) + { + return; + } + + _stateService.UnregisterComponentInstance(_instanceId); + _instanceId = null; } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -124,24 +143,49 @@ internal sealed class SamplePluginStatusClockWidget : Border RefreshStatusPanel(); } - private void RefreshClock() + private void SubscribeToPluginBus() { - _timeTextBlock.Text = DateTime.Now.ToString("HH:mm:ss"); + if (_subscriptions.Count > 0) + { + return; + } + + _subscriptions.Add(_messageBus.Subscribe(message => + Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime)))); + + _subscriptions.Add(_messageBus.Subscribe(_ => + Dispatcher.UIThread.Post(() => + { + UpdateSubtitle(); + RefreshStatusPanel(); + }))); + } + + private void RefreshClock(DateTimeOffset currentTime) + { + _timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss"); + } + + private void UpdateSubtitle() + { + var snapshot = _stateService.GetSnapshot(); + _subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId) + ? $"Preview surface | placed: {snapshot.PlacedCount}" + : $"Placement {_context.PlacementId} | placed: {snapshot.PlacedCount}"; } private void RefreshStatusPanel() { _statusPanel.Children.Clear(); + var snapshot = _stateService.GetSnapshot(); var basis = GetLayoutBasis(); - var titleSize = Math.Clamp(basis * 0.072, 11, 16); - var detailSize = Math.Clamp(basis * 0.055, 10, 13); + var titleSize = Math.Clamp(basis * 0.068, 11, 16); + var detailSize = Math.Clamp(basis * 0.052, 9, 13); - foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot()) + foreach (var entry in snapshot.StatusEntries) { 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), @@ -151,6 +195,7 @@ internal sealed class SamplePluginStatusClockWidget : Border Padding = new Thickness(10, 8), Child = new Grid { + RowDefinitions = new RowDefinitions("Auto,Auto"), ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8, Children = @@ -173,12 +218,19 @@ internal sealed class SamplePluginStatusClockWidget : Border }, new TextBlock { - Text = summaryText, + Text = entry.Summary, FontSize = detailSize, Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")), HorizontalAlignment = HorizontalAlignment.Right, TextAlignment = TextAlignment.Right, VerticalAlignment = VerticalAlignment.Center + }, + new TextBlock + { + Text = entry.Detail, + FontSize = detailSize, + Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")), + TextWrapping = TextWrapping.Wrap } } } @@ -187,6 +239,8 @@ internal sealed class SamplePluginStatusClockWidget : Border var row = (Grid)((Border)_statusPanel.Children[^1]).Child!; Grid.SetColumn(row.Children[1], 1); Grid.SetColumn(row.Children[2], 2); + Grid.SetColumnSpan(row.Children[3], 3); + Grid.SetRow(row.Children[3], 1); } } @@ -196,7 +250,10 @@ internal sealed class SamplePluginStatusClockWidget : Border 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); + _subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17); + _statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18)); + _statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22)); + _statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10); } private double GetLayoutBasis() diff --git a/LanMountainDesktop/Services/PluginRuntimeService.cs b/LanMountainDesktop/Services/PluginRuntimeService.cs index c109003..f3d6cd2 100644 --- a/LanMountainDesktop/Services/PluginRuntimeService.cs +++ b/LanMountainDesktop/Services/PluginRuntimeService.cs @@ -280,9 +280,22 @@ public sealed class PluginRuntimeService : IDisposable { var options = new PluginLoaderOptions(); AddSharedAssembly(options, typeof(App).Assembly); - AddSharedAssembly(options, typeof(Application).Assembly); - AddSharedAssembly(options, typeof(Control).Assembly); - AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + var assemblyName = assembly.GetName().Name; + if (string.IsNullOrWhiteSpace(assemblyName)) + { + continue; + } + + if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) || + string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase)) + { + AddSharedAssembly(options, assembly); + } + } + return options; } diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 6113da1..2542355 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -104,7 +104,7 @@ ClipToBounds="True" BorderThickness="0" PointerWheelChanged="OnDesktopPagesPointerWheelChanged"> - + @@ -439,7 +439,7 @@ Padding="0,0,16,0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> - +