mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
0.5.2
后端服务支持
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -18,6 +18,9 @@ public interface IPluginContext
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
void RegisterService<TService>(TService service)
|
||||
where TService : class;
|
||||
|
||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
|
||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginMessageBus
|
||||
{
|
||||
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||
|
||||
void Publish<TMessage>(TMessage message);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -125,14 +125,16 @@ public sealed class PluginLoader
|
||||
IReadOnlyDictionary<string, object?>? 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<string, PluginSettingsPageRegistration> _settingsPages =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, object> _registeredServices = [];
|
||||
private readonly List<object> _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<IPluginContext>(this);
|
||||
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
@@ -550,9 +590,16 @@ public sealed class PluginLoader
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RegisterService<TService>(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<PluginSettingsPageRegistration> 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<PluginDesktopComponentRegistration> 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>(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<object>(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<Type, List<Subscription>> _subscriptions = [];
|
||||
private readonly object _gate = new();
|
||||
private int _disposed;
|
||||
|
||||
public IDisposable Subscribe<TMessage>(Action<TMessage> 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>(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<object?> handler)
|
||||
{
|
||||
_owner = owner;
|
||||
MessageType = messageType;
|
||||
Handler = handler;
|
||||
}
|
||||
|
||||
public Type MessageType { get; }
|
||||
|
||||
public Action<object?> 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
|
||||
|
||||
@@ -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<string>("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<IPluginMessageBus>()
|
||||
?? 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<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SamplePluginStatusEntry> StatusEntries,
|
||||
bool HasPlacedComponent,
|
||||
int PlacedCount,
|
||||
int PreviewCount,
|
||||
IReadOnlyList<string> 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<string, SamplePluginComponentInstance> _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<SamplePluginCapabilityItem> 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<T>()",
|
||||
$"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.RegisterService<TService>()",
|
||||
"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<T>() 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<SamplePluginStatusEntry> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IDisposable> _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<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? 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<SamplePluginClockTickMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(RefreshView)));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
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<SamplePluginRuntimeStateService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService<SamplePluginClockService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService<IPluginMessageBus>() 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<SamplePluginRuntimeStateService>() is not null,
|
||||
_context.GetService<SamplePluginClockService>() is not null,
|
||||
_context.GetService<IPluginMessageBus>() 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
|
||||
|
||||
@@ -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<IDisposable> _subscriptions = [];
|
||||
private string? _instanceId;
|
||||
|
||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? 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<SamplePluginClockTickMessage>(message =>
|
||||
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
||||
<Grid x:Name="SettingsContentPagesHost">
|
||||
<Grid>
|
||||
<Grid x:Name="DesktopPagesHost"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top">
|
||||
@@ -439,7 +439,7 @@
|
||||
Padding="0,0,16,0"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<Grid>
|
||||
<Grid x:Name="SettingsContentPagesHost">
|
||||
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
|
||||
|
||||
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
|
||||
|
||||
Reference in New Issue
Block a user