mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +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_plugin_tabs
|
||||||
/_build_verify_sample_plugin
|
/_build_verify_sample_plugin
|
||||||
/_build_verify_sample_plugin_capabilities
|
/_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);
|
bool TryGetProperty<T>(string key, out T? value);
|
||||||
|
|
||||||
|
void RegisterService<TService>(TService service)
|
||||||
|
where TService : class;
|
||||||
|
|
||||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||||
|
|
||||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration 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();
|
disposable.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Context is IAsyncDisposable asyncContext)
|
||||||
|
{
|
||||||
|
await asyncContext.DisposeAsync();
|
||||||
|
}
|
||||||
|
else if (Context is IDisposable disposableContext)
|
||||||
|
{
|
||||||
|
disposableContext.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
LoadContext.Unload();
|
LoadContext.Unload();
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,14 +125,16 @@ public sealed class PluginLoader
|
|||||||
IReadOnlyDictionary<string, object?>? properties)
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
{
|
{
|
||||||
PluginLoadContext? loadContext = null;
|
PluginLoadContext? loadContext = null;
|
||||||
|
IPlugin? plugin = null;
|
||||||
|
PluginContext? context = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
||||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||||
var pluginType = ResolvePluginType(assembly);
|
var pluginType = ResolvePluginType(assembly);
|
||||||
var plugin = CreatePluginInstance(pluginType);
|
plugin = CreatePluginInstance(pluginType);
|
||||||
var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
||||||
|
|
||||||
plugin.Initialize(context);
|
plugin.Initialize(context);
|
||||||
var settingsPages = context.GetSettingsPagesSnapshot();
|
var settingsPages = context.GetSettingsPagesSnapshot();
|
||||||
@@ -153,6 +155,8 @@ public sealed class PluginLoader
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
DisposeInstance(plugin);
|
||||||
|
DisposeInstance(context);
|
||||||
loadContext?.Unload();
|
loadContext?.Unload();
|
||||||
return PluginLoadResult.Failure(sourcePath, manifest, ex);
|
return PluginLoadResult.Failure(sourcePath, manifest, ex);
|
||||||
}
|
}
|
||||||
@@ -477,6 +481,33 @@ public sealed class PluginLoader
|
|||||||
return plugin;
|
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)
|
private static Type[] GetLoadableTypes(Assembly assembly)
|
||||||
{
|
{
|
||||||
try
|
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 =
|
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
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(
|
public PluginContext(
|
||||||
PluginManifest manifest,
|
PluginManifest manifest,
|
||||||
@@ -517,8 +553,12 @@ public sealed class PluginLoader
|
|||||||
Manifest = manifest;
|
Manifest = manifest;
|
||||||
PluginDirectory = pluginDirectory;
|
PluginDirectory = pluginDirectory;
|
||||||
DataDirectory = dataDirectory;
|
DataDirectory = dataDirectory;
|
||||||
Services = services;
|
_hostServices = services;
|
||||||
|
Services = new PluginCompositeServiceProvider(this);
|
||||||
Properties = properties;
|
Properties = properties;
|
||||||
|
|
||||||
|
RegisterBuiltInService<IPluginContext>(this);
|
||||||
|
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginManifest Manifest { get; }
|
public PluginManifest Manifest { get; }
|
||||||
@@ -550,9 +590,16 @@ public sealed class PluginLoader
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RegisterService<TService>(TService service)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
RegisterServiceCore(typeof(TService), service, allowOverride: false);
|
||||||
|
}
|
||||||
|
|
||||||
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
if (!_settingsPages.TryAdd(registration.Id, registration))
|
if (!_settingsPages.TryAdd(registration.Id, registration))
|
||||||
{
|
{
|
||||||
@@ -564,6 +611,7 @@ public sealed class PluginLoader
|
|||||||
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
|
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
||||||
{
|
{
|
||||||
@@ -574,6 +622,7 @@ public sealed class PluginLoader
|
|||||||
|
|
||||||
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
||||||
{
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
return _settingsPages.Values
|
return _settingsPages.Values
|
||||||
.OrderBy(page => page.SortOrder)
|
.OrderBy(page => page.SortOrder)
|
||||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -582,11 +631,270 @@ public sealed class PluginLoader
|
|||||||
|
|
||||||
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
||||||
{
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
return _desktopComponents.Values
|
return _desktopComponents.Values
|
||||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.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
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
|||||||
@@ -5,39 +5,49 @@ namespace LanMountainDesktop.SamplePlugin;
|
|||||||
[PluginEntrance]
|
[PluginEntrance]
|
||||||
public sealed class SamplePlugin : PluginBase, IDisposable
|
public sealed class SamplePlugin : PluginBase, IDisposable
|
||||||
{
|
{
|
||||||
private SamplePluginHeartbeatService? _heartbeatService;
|
private SamplePluginRuntimeStateService? _stateService;
|
||||||
|
private SamplePluginClockService? _clockService;
|
||||||
|
|
||||||
public override void Initialize(IPluginContext context)
|
public override void Initialize(IPluginContext context)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(context.DataDirectory);
|
Directory.CreateDirectory(context.DataDirectory);
|
||||||
|
|
||||||
var hostName = context.TryGetProperty<string>("HostApplicationName", out var configuredHostName) &&
|
var hostName = GetHostProperty(context, "HostApplicationName", "UnknownHost");
|
||||||
!string.IsNullOrWhiteSpace(configuredHostName)
|
var hostVersion = GetHostProperty(context, "HostVersion", "UnknownVersion");
|
||||||
? configuredHostName
|
var sdkApiVersion = GetHostProperty(context, "PluginSdkApiVersion", "UnknownApiVersion");
|
||||||
: "UnknownHost";
|
var messageBus = context.GetService<IPluginMessageBus>()
|
||||||
|
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||||
|
|
||||||
var version = context.Manifest.Version ?? "dev";
|
_stateService = new SamplePluginRuntimeStateService(
|
||||||
SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory);
|
context.Manifest,
|
||||||
|
context.PluginDirectory,
|
||||||
|
context.DataDirectory,
|
||||||
|
hostName,
|
||||||
|
hostVersion,
|
||||||
|
sdkApiVersion,
|
||||||
|
messageBus);
|
||||||
|
context.RegisterService(_stateService);
|
||||||
|
|
||||||
var message =
|
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus);
|
||||||
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version}).";
|
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
|
try
|
||||||
{
|
{
|
||||||
File.AppendAllText(
|
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
||||||
Path.Combine(context.DataDirectory, "sample-plugin.log"),
|
_stateService.MarkBackendReady($"Initialization log written to {logPath}.");
|
||||||
message + Environment.NewLine);
|
|
||||||
SamplePluginRuntimeStatus.MarkBackendReady(
|
|
||||||
$"Plugin entry initialized successfully. Host: {hostName}; Version: {version}");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
|
_stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
_heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory);
|
_clockService.Start();
|
||||||
_heartbeatService.Start();
|
|
||||||
|
|
||||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||||
"status",
|
"status",
|
||||||
@@ -60,7 +70,15 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_heartbeatService?.Dispose();
|
_clockService?.Dispose();
|
||||||
_heartbeatService = null;
|
_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.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop.SamplePlugin;
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
@@ -19,75 +21,127 @@ internal sealed record SamplePluginStatusEntry(
|
|||||||
string Detail,
|
string Detail,
|
||||||
DateTimeOffset UpdatedAt);
|
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(
|
internal sealed class SamplePluginRuntimeStateService
|
||||||
"frontend",
|
{
|
||||||
"Frontend",
|
private readonly object _gate = new();
|
||||||
SamplePluginHealthState.Pending,
|
private readonly IPluginMessageBus _messageBus;
|
||||||
"Pending",
|
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
|
||||||
"Frontend surfaces have not been created yet.");
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static SamplePluginStatusEntry _component = CreateEntry(
|
private readonly PluginManifest _manifest;
|
||||||
"component",
|
private readonly string _pluginDirectory;
|
||||||
"Component",
|
private readonly string _dataDirectory;
|
||||||
SamplePluginHealthState.Pending,
|
private readonly string _hostApplicationName;
|
||||||
"Pending",
|
private readonly string _hostVersion;
|
||||||
"The 4x4 component has not been created yet.");
|
private readonly string _sdkApiVersion;
|
||||||
|
|
||||||
private static SamplePluginStatusEntry _backend = CreateEntry(
|
private SamplePluginStatusEntry _frontend;
|
||||||
"backend",
|
private SamplePluginStatusEntry _component;
|
||||||
"Backend",
|
private SamplePluginStatusEntry _backend;
|
||||||
SamplePluginHealthState.Pending,
|
private SamplePluginStatusEntry _service;
|
||||||
"Pending",
|
private string? _lastComponentId;
|
||||||
"Plugin initialization has not finished yet.");
|
private double _lastCellSize;
|
||||||
|
private DateTimeOffset? _serviceClockTime;
|
||||||
|
|
||||||
private static SamplePluginStatusEntry _service = CreateEntry(
|
public SamplePluginRuntimeStateService(
|
||||||
"service",
|
PluginManifest manifest,
|
||||||
"Service",
|
string pluginDirectory,
|
||||||
SamplePluginHealthState.Pending,
|
string dataDirectory,
|
||||||
"Pending",
|
string hostApplicationName,
|
||||||
"Heartbeat service has not started yet.");
|
string hostVersion,
|
||||||
|
string sdkApiVersion,
|
||||||
public static void Reset(string hostName, string version, string dataDirectory)
|
IPluginMessageBus messageBus)
|
||||||
{
|
{
|
||||||
lock (Gate)
|
_manifest = manifest;
|
||||||
{
|
_pluginDirectory = pluginDirectory;
|
||||||
_frontend = CreateEntry(
|
_dataDirectory = dataDirectory;
|
||||||
"frontend",
|
_hostApplicationName = hostApplicationName;
|
||||||
"Frontend",
|
_hostVersion = hostVersion;
|
||||||
SamplePluginHealthState.Pending,
|
_sdkApiVersion = sdkApiVersion;
|
||||||
"Pending",
|
_messageBus = messageBus;
|
||||||
"Waiting for the settings page or widget surface to render.");
|
|
||||||
|
|
||||||
_component = CreateEntry(
|
_frontend = CreateEntry(
|
||||||
"component",
|
"frontend",
|
||||||
"Component",
|
"Frontend",
|
||||||
SamplePluginHealthState.Pending,
|
SamplePluginHealthState.Pending,
|
||||||
"Pending",
|
"Pending",
|
||||||
"The 4x4 component has not been created yet.");
|
"Waiting for a plugin UI surface to connect.");
|
||||||
|
|
||||||
_backend = CreateEntry(
|
_component = CreateEntry(
|
||||||
"backend",
|
"component",
|
||||||
"Backend",
|
"Component",
|
||||||
SamplePluginHealthState.Healthy,
|
SamplePluginHealthState.Pending,
|
||||||
"Healthy",
|
"Pending",
|
||||||
$"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}");
|
"No component instance has been created yet.");
|
||||||
|
|
||||||
_service = CreateEntry(
|
_backend = CreateEntry(
|
||||||
"service",
|
"backend",
|
||||||
"Service",
|
"Backend",
|
||||||
SamplePluginHealthState.Pending,
|
SamplePluginHealthState.Pending,
|
||||||
"Pending",
|
"Pending",
|
||||||
"Heartbeat service is starting.");
|
"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 = CreateEntry(
|
||||||
"frontend",
|
"frontend",
|
||||||
@@ -96,85 +150,220 @@ internal static class SamplePluginRuntimeStatus
|
|||||||
"Healthy",
|
"Healthy",
|
||||||
detail);
|
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 = CreateEntry(
|
||||||
"component",
|
"component",
|
||||||
"Component",
|
"Component",
|
||||||
SamplePluginHealthState.Healthy,
|
SamplePluginHealthState.Healthy,
|
||||||
"Created",
|
"Placed",
|
||||||
detail);
|
$"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public static void MarkBackendReady(string detail)
|
if (previewCount > 0)
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
{
|
||||||
_backend = CreateEntry(
|
_component = CreateEntry(
|
||||||
"backend",
|
"component",
|
||||||
"Backend",
|
"Component",
|
||||||
SamplePluginHealthState.Healthy,
|
SamplePluginHealthState.Healthy,
|
||||||
"Healthy",
|
"Preview",
|
||||||
detail);
|
$"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)
|
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
|
||||||
{
|
|
||||||
_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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SamplePluginStatusEntry CreateEntry(
|
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 readonly Timer _timer;
|
||||||
|
private DateTimeOffset _currentTime = DateTimeOffset.Now;
|
||||||
private int _disposed;
|
private int _disposed;
|
||||||
|
|
||||||
public SamplePluginHeartbeatService(string dataDirectory)
|
public SamplePluginClockService(
|
||||||
|
string dataDirectory,
|
||||||
|
SamplePluginRuntimeStateService stateService,
|
||||||
|
IPluginMessageBus messageBus)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(dataDirectory);
|
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
|
||||||
_heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt");
|
_stateService = stateService;
|
||||||
|
_messageBus = messageBus;
|
||||||
_timer = new Timer(OnTimerTick);
|
_timer = new Timer(OnTimerTick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset CurrentTime
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
PublishHeartbeat();
|
PublishTick();
|
||||||
_timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -225,10 +433,10 @@ internal sealed class SamplePluginHeartbeatService : IDisposable
|
|||||||
|
|
||||||
private void OnTimerTick(object? state)
|
private void OnTimerTick(object? state)
|
||||||
{
|
{
|
||||||
PublishHeartbeat();
|
PublishTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PublishHeartbeat()
|
private void PublishTick()
|
||||||
{
|
{
|
||||||
if (Volatile.Read(ref _disposed) != 0)
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
{
|
{
|
||||||
@@ -236,16 +444,22 @@ internal sealed class SamplePluginHeartbeatService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
var now = DateTimeOffset.Now;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_currentTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllText(
|
File.WriteAllText(
|
||||||
_heartbeatFilePath,
|
_clockStateFilePath,
|
||||||
now.ToString("O", CultureInfo.InvariantCulture));
|
now.ToString("O", CultureInfo.InvariantCulture));
|
||||||
SamplePluginRuntimeStatus.MarkServiceHeartbeat(now);
|
_stateService.MarkClockServiceTick(now);
|
||||||
|
_messageBus.Publish(new SamplePluginClockTickMessage(now));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
internal sealed class SamplePluginSettingsView : UserControl
|
||||||
{
|
{
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromSeconds(1)
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly IPluginContext _context;
|
private readonly IPluginContext _context;
|
||||||
private readonly TextBlock _summaryTextBlock;
|
private readonly SamplePluginRuntimeStateService _stateService;
|
||||||
private readonly StackPanel _statusPanel;
|
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)
|
public SamplePluginSettingsView(IPluginContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_summaryTextBlock = new TextBlock
|
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||||
{
|
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
|
_clockService = context.GetService<SamplePluginClockService>()
|
||||||
TextWrapping = TextWrapping.Wrap
|
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||||
};
|
_messageBus = context.GetService<IPluginMessageBus>()
|
||||||
_statusPanel = new StackPanel
|
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||||
{
|
|
||||||
Spacing = 10
|
|
||||||
};
|
|
||||||
|
|
||||||
SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully.");
|
_stateService.MarkFrontendReady("Settings page is connected to plugin services and communication.");
|
||||||
|
|
||||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
|
||||||
@@ -60,51 +56,110 @@ internal sealed class SamplePluginSettingsView : UserControl
|
|||||||
{
|
{
|
||||||
new TextBlock
|
new TextBlock
|
||||||
{
|
{
|
||||||
Text = "Sample Plugin Runtime Status",
|
Text = "Sample Plugin Capability Inspector",
|
||||||
FontSize = 22,
|
FontSize = 22,
|
||||||
FontWeight = FontWeight.SemiBold,
|
FontWeight = FontWeight.SemiBold,
|
||||||
Foreground = Brushes.White
|
Foreground = Brushes.White
|
||||||
},
|
},
|
||||||
_summaryTextBlock,
|
CreateSection("Plugin Info", _pluginInfoPanel),
|
||||||
new Border
|
CreateSection("Accessible Capabilities", _capabilityPanel),
|
||||||
{
|
CreateSection("Live Runtime Status", _statusPanel)
|
||||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(14),
|
|
||||||
Padding = new Thickness(14),
|
|
||||||
Child = _statusPanel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
RefreshStatuses();
|
RefreshView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
RefreshStatuses();
|
SubscribeToPluginBus();
|
||||||
_refreshTimer.Start();
|
RefreshView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
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 =
|
var snapshot = _stateService.GetSnapshot();
|
||||||
$"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}";
|
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();
|
_statusPanel.Children.Clear();
|
||||||
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
|
|
||||||
|
foreach (var entry in snapshot.StatusEntries)
|
||||||
{
|
{
|
||||||
var palette = GetPalette(entry.State);
|
var palette = GetPalette(entry.State);
|
||||||
_statusPanel.Children.Add(new Border
|
_statusPanel.Children.Add(new Border
|
||||||
@@ -119,35 +174,7 @@ internal sealed class SamplePluginSettingsView : UserControl
|
|||||||
Spacing = 4,
|
Spacing = 4,
|
||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
new Grid
|
CreateStatusHeader(entry, palette),
|
||||||
{
|
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
|
||||||
ColumnSpacing = 8,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new Border
|
|
||||||
{
|
|
||||||
Width = 10,
|
|
||||||
Height = 10,
|
|
||||||
CornerRadius = new CornerRadius(999),
|
|
||||||
Background = new SolidColorBrush(palette.Dot),
|
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Title,
|
|
||||||
FontSize = 15,
|
|
||||||
FontWeight = FontWeight.SemiBold,
|
|
||||||
Foreground = Brushes.White
|
|
||||||
},
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = entry.Summary,
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
new TextBlock
|
new TextBlock
|
||||||
{
|
{
|
||||||
Text = entry.Detail,
|
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)
|
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||||
{
|
{
|
||||||
return state switch
|
return state switch
|
||||||
|
|||||||
@@ -9,35 +9,50 @@ namespace LanMountainDesktop.SamplePlugin;
|
|||||||
|
|
||||||
internal sealed class SamplePluginStatusClockWidget : Border
|
internal sealed class SamplePluginStatusClockWidget : Border
|
||||||
{
|
{
|
||||||
private readonly DispatcherTimer _timer = new()
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromSeconds(1)
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly PluginDesktopComponentContext _context;
|
private readonly PluginDesktopComponentContext _context;
|
||||||
|
private readonly SamplePluginRuntimeStateService _stateService;
|
||||||
|
private readonly SamplePluginClockService _clockService;
|
||||||
|
private readonly IPluginMessageBus _messageBus;
|
||||||
private readonly TextBlock _timeTextBlock;
|
private readonly TextBlock _timeTextBlock;
|
||||||
private readonly TextBlock _titleTextBlock;
|
private readonly TextBlock _subtitleTextBlock;
|
||||||
private readonly StackPanel _statusPanel;
|
private readonly StackPanel _statusPanel;
|
||||||
|
private readonly Border _statusHost;
|
||||||
|
private readonly List<IDisposable> _subscriptions = [];
|
||||||
|
private string? _instanceId;
|
||||||
|
|
||||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||||
{
|
{
|
||||||
_context = 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
|
_timeTextBlock = new TextBlock
|
||||||
{
|
{
|
||||||
Foreground = Brushes.White,
|
Foreground = Brushes.White,
|
||||||
FontWeight = FontWeight.Bold,
|
FontWeight = FontWeight.Bold,
|
||||||
HorizontalAlignment = HorizontalAlignment.Left
|
HorizontalAlignment = HorizontalAlignment.Left
|
||||||
};
|
};
|
||||||
_titleTextBlock = new TextBlock
|
_subtitleTextBlock = new TextBlock
|
||||||
{
|
{
|
||||||
Text = "Plugin Status",
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||||
HorizontalAlignment = HorizontalAlignment.Left
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
};
|
};
|
||||||
_statusPanel = new StackPanel
|
_statusPanel = new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 8
|
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
|
Background = new LinearGradientBrush
|
||||||
{
|
{
|
||||||
@@ -67,55 +82,59 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
|||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
_timeTextBlock,
|
_timeTextBlock,
|
||||||
_titleTextBlock
|
_subtitleTextBlock
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
new Border
|
_statusHost
|
||||||
{
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
|
||||||
BorderThickness = new Thickness(1),
|
|
||||||
CornerRadius = new CornerRadius(18),
|
|
||||||
Padding = new Thickness(12),
|
|
||||||
Child = _statusPanel
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||||
|
|
||||||
_timer.Tick += OnTimerTick;
|
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
var placementText = string.IsNullOrWhiteSpace(context.PlacementId)
|
RefreshClock(_clockService.CurrentTime);
|
||||||
? "Preview instance created."
|
UpdateSubtitle();
|
||||||
: $"Widget created for placement {context.PlacementId}.";
|
|
||||||
SamplePluginRuntimeStatus.MarkFrontendReady("Widget frontend surface rendered successfully.");
|
|
||||||
SamplePluginRuntimeStatus.MarkComponentCreated($"{placementText} Baseline footprint: 4x4.");
|
|
||||||
|
|
||||||
RefreshClock();
|
|
||||||
RefreshStatusPanel();
|
RefreshStatusPanel();
|
||||||
ApplyScale();
|
ApplyScale();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
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();
|
RefreshStatusPanel();
|
||||||
_timer.Start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_timer.Stop();
|
foreach (var subscription in _subscriptions)
|
||||||
}
|
{
|
||||||
|
subscription.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTimerTick(object? sender, EventArgs e)
|
_subscriptions.Clear();
|
||||||
{
|
|
||||||
RefreshClock();
|
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||||
RefreshStatusPanel();
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateService.UnregisterComponentInstance(_instanceId);
|
||||||
|
_instanceId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
@@ -124,24 +143,49 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
|||||||
RefreshStatusPanel();
|
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()
|
private void RefreshStatusPanel()
|
||||||
{
|
{
|
||||||
_statusPanel.Children.Clear();
|
_statusPanel.Children.Clear();
|
||||||
|
|
||||||
|
var snapshot = _stateService.GetSnapshot();
|
||||||
var basis = GetLayoutBasis();
|
var basis = GetLayoutBasis();
|
||||||
var titleSize = Math.Clamp(basis * 0.072, 11, 16);
|
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
|
||||||
var detailSize = Math.Clamp(basis * 0.055, 10, 13);
|
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 palette = GetPalette(entry.State);
|
||||||
var summaryText = $"{entry.Summary} - {entry.UpdatedAt.LocalDateTime:HH:mm:ss}";
|
|
||||||
|
|
||||||
_statusPanel.Children.Add(new Border
|
_statusPanel.Children.Add(new Border
|
||||||
{
|
{
|
||||||
Background = new SolidColorBrush(palette.Background),
|
Background = new SolidColorBrush(palette.Background),
|
||||||
@@ -151,6 +195,7 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
|||||||
Padding = new Thickness(10, 8),
|
Padding = new Thickness(10, 8),
|
||||||
Child = new Grid
|
Child = new Grid
|
||||||
{
|
{
|
||||||
|
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
||||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||||
ColumnSpacing = 8,
|
ColumnSpacing = 8,
|
||||||
Children =
|
Children =
|
||||||
@@ -173,12 +218,19 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
|||||||
},
|
},
|
||||||
new TextBlock
|
new TextBlock
|
||||||
{
|
{
|
||||||
Text = summaryText,
|
Text = entry.Summary,
|
||||||
FontSize = detailSize,
|
FontSize = detailSize,
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
TextAlignment = TextAlignment.Right,
|
TextAlignment = TextAlignment.Right,
|
||||||
VerticalAlignment = VerticalAlignment.Center
|
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!;
|
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
|
||||||
Grid.SetColumn(row.Children[1], 1);
|
Grid.SetColumn(row.Children[1], 1);
|
||||||
Grid.SetColumn(row.Children[2], 2);
|
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));
|
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
|
||||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
||||||
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
|
_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()
|
private double GetLayoutBasis()
|
||||||
|
|||||||
@@ -280,9 +280,22 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
{
|
{
|
||||||
var options = new PluginLoaderOptions();
|
var options = new PluginLoaderOptions();
|
||||||
AddSharedAssembly(options, typeof(App).Assembly);
|
AddSharedAssembly(options, typeof(App).Assembly);
|
||||||
AddSharedAssembly(options, typeof(Application).Assembly);
|
|
||||||
AddSharedAssembly(options, typeof(Control).Assembly);
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly);
|
{
|
||||||
|
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;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
ClipToBounds="True"
|
ClipToBounds="True"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
||||||
<Grid x:Name="SettingsContentPagesHost">
|
<Grid>
|
||||||
<Grid x:Name="DesktopPagesHost"
|
<Grid x:Name="DesktopPagesHost"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
VerticalAlignment="Top">
|
VerticalAlignment="Top">
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
Padding="0,0,16,0"
|
Padding="0,0,16,0"
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
VerticalScrollBarVisibility="Auto">
|
VerticalScrollBarVisibility="Auto">
|
||||||
<Grid>
|
<Grid x:Name="SettingsContentPagesHost">
|
||||||
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
|
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
|
||||||
|
|
||||||
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
|
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
|
||||||
|
|||||||
Reference in New Issue
Block a user