后端服务支持
This commit is contained in:
lincube
2026-03-09 14:14:50 +08:00
parent cab35f4c22
commit 103b215e35
11 changed files with 1058 additions and 276 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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);

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginMessageBus
{
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
void Publish<TMessage>(TMessage message);
}

View File

@@ -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);
} }

View File

@@ -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

View File

@@ -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;
} }
} }

View File

@@ -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}");
} }
} }
} }

View File

@@ -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

View File

@@ -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()

View File

@@ -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;
} }

View File

@@ -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" />