Compare commits

..

2 Commits

Author SHA1 Message Date
lincube
8bb6b01236 0.5.3
试验性引入渲染模式切换
2026-03-09 15:11:48 +08:00
lincube
103b215e35 0.5.2
后端服务支持
2026-03-09 14:14:50 +08:00
25 changed files with 1334 additions and 280 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;
_dataDirectory = dataDirectory;
_hostApplicationName = hostApplicationName;
_hostVersion = hostVersion;
_sdkApiVersion = sdkApiVersion;
_messageBus = messageBus;
_frontend = CreateEntry( _frontend = CreateEntry(
"frontend", "frontend",
"Frontend", "Frontend",
SamplePluginHealthState.Pending, SamplePluginHealthState.Pending,
"Pending", "Pending",
"Waiting for the settings page or widget surface to render."); "Waiting for a plugin UI surface to connect.");
_component = CreateEntry( _component = CreateEntry(
"component", "component",
"Component", "Component",
SamplePluginHealthState.Pending, SamplePluginHealthState.Pending,
"Pending", "Pending",
"The 4x4 component has not been created yet."); "No component instance has been created yet.");
_backend = CreateEntry( _backend = CreateEntry(
"backend", "backend",
"Backend", "Backend",
SamplePluginHealthState.Healthy, SamplePluginHealthState.Pending,
"Healthy", "Pending",
$"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}"); "Plugin initialization is in progress.");
_service = CreateEntry( _service = CreateEntry(
"service", "service",
"Service", "Clock Service",
SamplePluginHealthState.Pending, SamplePluginHealthState.Pending,
"Pending", "Pending",
"Heartbeat service is starting."); "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,24 +150,13 @@ 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)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Created",
detail);
}
}
public static void MarkBackendReady(string detail)
{
lock (Gate)
{ {
_backend = CreateEntry( _backend = CreateEntry(
"backend", "backend",
@@ -122,11 +165,13 @@ internal static class SamplePluginRuntimeStatus
"Healthy", "Healthy",
detail); detail);
} }
PublishStateChanged("Backend updated");
} }
public static void MarkBackendFaulted(string detail) public void MarkBackendFaulted(string detail)
{ {
lock (Gate) lock (_gate)
{ {
_backend = CreateEntry( _backend = CreateEntry(
"backend", "backend",
@@ -135,46 +180,190 @@ internal static class SamplePluginRuntimeStatus
"Faulted", "Faulted",
detail); detail);
} }
PublishStateChanged("Backend faulted");
} }
public static void MarkServiceHeartbeat(DateTimeOffset timestamp) public void MarkClockServiceTick(DateTimeOffset currentTime)
{ {
lock (Gate) lock (_gate)
{ {
_serviceClockTime = currentTime;
_service = CreateEntry( _service = CreateEntry(
"service", "service",
"Service", "Clock Service",
SamplePluginHealthState.Healthy, SamplePluginHealthState.Healthy,
"Healthy", "Healthy",
$"Heartbeat service is running. Last heartbeat: {timestamp.LocalDateTime:HH:mm:ss}"); $"Clock service is running. Current service time: {currentTime.LocalDateTime:HH:mm:ss}");
}
} }
public static void MarkServiceFaulted(string detail) PublishStateChanged("Clock service tick");
}
public void MarkClockServiceFaulted(string detail)
{ {
lock (Gate) lock (_gate)
{ {
_service = CreateEntry( _service = CreateEntry(
"service", "service",
"Service", "Clock Service",
SamplePluginHealthState.Faulted, SamplePluginHealthState.Faulted,
"Faulted", "Faulted",
detail); detail);
} }
PublishStateChanged("Clock service faulted");
} }
public static IReadOnlyList<SamplePluginStatusEntry> GetSnapshot() public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
{ {
lock (Gate) 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 return
[ [
_frontend, new SamplePluginCapabilityItem(
_component, "IPluginContext.Manifest",
_backend, $"Readable. Current plugin id: {context.Manifest.Id}; version: {context.Manifest.Version ?? "dev"}."),
_service new SamplePluginCapabilityItem(
"IPluginContext.PluginDirectory / DataDirectory",
$"Readable. Plugin directory: {context.PluginDirectory}; data directory: {context.DataDirectory}."),
new SamplePluginCapabilityItem(
"IPluginContext.Properties",
$"Readable. Host properties currently exposed: {propertyNames}."),
new SamplePluginCapabilityItem(
"IPluginContext.GetService<T>()",
$"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."),
new SamplePluginCapabilityItem(
"IPluginContext.RegisterService<TService>()",
"Callable during plugin initialization. This plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container."),
new SamplePluginCapabilityItem(
"Plugin communication bus",
"This plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces."),
new SamplePluginCapabilityItem(
"PluginDesktopComponentContext",
"Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.")
]; ];
} }
private void UpdateComponentStatusNoLock()
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
if (placementIds.Length > 0)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Placed",
$"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}");
return;
}
if (previewCount > 0)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Preview",
$"Preview instances: {previewCount}; no placed desktop instance is active yet.");
return;
}
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"No component instance is active.");
}
private void PublishStateChanged(string reason)
{
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
} }
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();
} }
private void OnRefreshTimerTick(object? sender, EventArgs e) _subscriptions.Clear();
{
RefreshStatuses();
} }
private void RefreshStatuses() private void SubscribeToPluginBus()
{ {
_summaryTextBlock.Text = if (_subscriptions.Count > 0)
$"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}"; {
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
}
private void RefreshView()
{
var snapshot = _stateService.GetSnapshot();
RefreshPluginInfo(snapshot);
RefreshCapabilities();
RefreshStatuses(snapshot);
}
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
{
_pluginInfoPanel.Children.Clear();
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Name", snapshot.Manifest.Name));
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Id", snapshot.Manifest.Id));
_pluginInfoPanel.Children.Add(CreateInfoLine("Version", snapshot.Manifest.Version ?? "dev"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Author", snapshot.Manifest.Author ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Description", snapshot.Manifest.Description ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Directory", snapshot.PluginDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine("Data Directory", snapshot.DataDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Application", snapshot.HostApplicationName));
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Version", snapshot.HostVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine("SDK API Version", snapshot.SdkApiVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine("State Service Resolved", (_context.GetService<SamplePluginRuntimeStateService>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService<SamplePluginClockService>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService<IPluginMessageBus>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Component Placed", snapshot.HasPlacedComponent ? "Yes" : "No"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Placed Count", snapshot.PlacedCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Preview Count", snapshot.PreviewCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Placement Ids",
snapshot.PlacementIds.Count == 0 ? "(none)" : string.Join(", ", snapshot.PlacementIds)));
_pluginInfoPanel.Children.Add(CreateInfoLine("Last Component Id", snapshot.LastComponentId ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Last Cell Size",
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : "(unknown)"));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Clock Service Time",
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
}
private void RefreshCapabilities()
{
var capabilities = _stateService.GetCapabilities(
_context,
_context.GetService<SamplePluginRuntimeStateService>() is not null,
_context.GetService<SamplePluginClockService>() is not null,
_context.GetService<IPluginMessageBus>() is not null);
_capabilityPanel.Children.Clear();
foreach (var capability in capabilities)
{
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
}
}
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
{
_statusPanel.Children.Clear(); _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();
if (string.IsNullOrWhiteSpace(_instanceId))
{ {
RefreshClock(); return;
RefreshStatusPanel(); }
_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

@@ -237,6 +237,13 @@
"settings.about.startup_header": "Windows Startup", "settings.about.startup_header": "Windows Startup",
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.", "settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
"settings.about.startup_toggle": "Launch at Windows sign-in", "settings.about.startup_toggle": "Launch at Windows sign-in",
"settings.about.render_mode_header": "App Rendering Mode",
"settings.about.render_mode_desc": "Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.",
"settings.about.render_mode.default": "Default",
"settings.about.render_mode.software": "Software",
"settings.about.render_mode.angle_egl": "angleEgl",
"settings.about.render_mode.wgl": "WGL",
"settings.about.render_mode.vulkan": "Vulkan",
"settings.footer": "LanMountainDesktop Settings", "settings.footer": "LanMountainDesktop Settings",
"filepicker.title": "Select wallpaper", "filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files", "filepicker.image_files": "Image files",

View File

@@ -237,6 +237,13 @@
"settings.about.startup_header": "Windows 自启动", "settings.about.startup_header": "Windows 自启动",
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。", "settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
"settings.about.startup_toggle": "登录 Windows 时启动", "settings.about.startup_toggle": "登录 Windows 时启动",
"settings.about.render_mode_header": "应用渲染模式",
"settings.about.render_mode_desc": "选择应用渲染后端。更改后需要重启应用生效。不支持的模式会回退到软件渲染。",
"settings.about.render_mode.default": "默认",
"settings.about.render_mode.software": "软件",
"settings.about.render_mode.angle_egl": "angleEgl",
"settings.about.render_mode.wgl": "WGL",
"settings.about.render_mode.vulkan": "Vulkan",
"settings.footer": "LanMountainDesktop 设置", "settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸", "filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件", "filepicker.image_files": "图片文件",

View File

@@ -48,6 +48,8 @@ public sealed class AppSettingsSnapshot
public bool AutoStartWithWindows { get; set; } public bool AutoStartWithWindows { get; set; }
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true; public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; } public bool IncludePrereleaseUpdates { get; set; }

View File

@@ -1,5 +1,6 @@
using Avalonia; using Avalonia;
using Avalonia.WebView.Desktop; using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
using System; using System;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -10,14 +11,42 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break. // yet and stuff might break.
[STAThread] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp() public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
.StartWithClassicDesktopLifetime(args); .StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer. // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
=> AppBuilder.Configure<App>() {
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.UseDesktopWebView() .UseDesktopWebView()
.WithInterFont() .WithInterFont()
.LogToTrace(); .LogToTrace();
if (OperatingSystem.IsWindows())
{
var configuredModes = AppRenderingModeHelper.GetWin32RenderingModes(renderMode);
if (configuredModes is { Length: > 0 })
{
builder = builder.With(new Win32PlatformOptions
{
RenderingMode = configuredModes
});
}
}
return builder;
}
private static string LoadConfiguredRenderMode()
{
try
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
}
catch
{
return AppRenderingModeHelper.Default;
}
}
} }

View File

@@ -0,0 +1,42 @@
using Avalonia;
namespace LanMountainDesktop.Services;
public static class AppRenderingModeHelper
{
public const string Default = "Default";
public const string Software = "Software";
public const string AngleEgl = "AngleEgl";
public const string Wgl = "Wgl";
public const string Vulkan = "Vulkan";
public static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Default;
}
return value.Trim().ToUpperInvariant() switch
{
"SOFTWARE" => Software,
"ANGLEEGL" => AngleEgl,
"ANGLE_EGL" => AngleEgl,
"WGL" => Wgl,
"VULKAN" => Vulkan,
_ => Default
};
}
public static Win32RenderingMode[]? GetWin32RenderingModes(string? value)
{
return Normalize(value) switch
{
Software => [Win32RenderingMode.Software],
AngleEgl => [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software],
Wgl => [Win32RenderingMode.Wgl, Win32RenderingMode.Software],
Vulkan => [Win32RenderingMode.Vulkan, Win32RenderingMode.Software],
_ => null
};
}
}

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

@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using FluentIcons.Avalonia; using FluentIcons.Avalonia;
using FluentIcons.Common; using FluentIcons.Common;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -315,6 +317,15 @@ public partial class MainWindow
AboutStartupSettingsExpander.Description = L( AboutStartupSettingsExpander.Description = L(
"settings.about.startup_desc", "settings.about.startup_desc",
"Launch the app automatically when signing in to Windows."); "Launch the app automatically when signing in to Windows.");
AboutRenderModeSettingsExpander.Header = L("settings.about.render_mode_header", "Rendering Mode");
AboutRenderModeSettingsExpander.Description = L(
"settings.about.render_mode_desc",
"Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.");
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Default, L("settings.about.render_mode.default", "Default"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Software, L("settings.about.render_mode.software", "Software"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
if (WallpaperPlacementComboBox?.ItemCount >= 5) if (WallpaperPlacementComboBox?.ItemCount >= 5)
{ {
@@ -341,6 +352,19 @@ public partial class MainWindow
UpdateWallpaperDisplay(); UpdateWallpaperDisplay();
} }
private void SetAppRenderModeComboItemContent(string tag, string content)
{
var item = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(candidate =>
string.Equals(candidate.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
item.Content = content;
}
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone) private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{ {
var offset = timeZone.GetUtcOffset(DateTime.UtcNow); var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -978,6 +978,7 @@ public partial class MainWindow
InitializeWeatherSettings(snapshot); InitializeWeatherSettings(snapshot);
_ = _componentSettingsService.Load(); _ = _componentSettingsService.Load();
InitializeAutoStartWithWindowsSetting(snapshot); InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot); InitializeUpdateSettings(snapshot);
InitializeDesktopSurfaceState(desktopLayoutSnapshot); InitializeDesktopSurfaceState(desktopLayoutSnapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot); InitializeLauncherVisibilitySettings(launcherSnapshot);
@@ -1040,6 +1041,7 @@ public partial class MainWindow
snapshot.WeatherIconPackId = _weatherIconPackId; snapshot.WeatherIconPackId = _weatherIconPackId;
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests; snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
snapshot.AutoStartWithWindows = _autoStartWithWindows; snapshot.AutoStartWithWindows = _autoStartWithWindows;
snapshot.AppRenderMode = _selectedAppRenderMode;
snapshot.AutoCheckUpdates = _autoCheckUpdates; snapshot.AutoCheckUpdates = _autoCheckUpdates;
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates; snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable; snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;
@@ -1220,6 +1222,43 @@ public partial class MainWindow
} }
} }
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
{
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
if (AppRenderModeComboBox is null)
{
return;
}
_suppressAppRenderModeSelectionEvents = true;
try
{
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
SelectAppRenderModeInUi(_selectedAppRenderMode);
}
finally
{
_suppressAppRenderModeSelectionEvents = false;
}
}
private void SelectAppRenderModeInUi(string renderMode)
{
if (AppRenderModeComboBox is null)
{
return;
}
var selectedItem = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
string.Equals(item.Tag?.ToString(), renderMode, StringComparison.OrdinalIgnoreCase));
AppRenderModeComboBox.SelectedItem = selectedItem
?? AppRenderModeComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static WeatherLocationMode ParseWeatherLocationMode(string? value) private static WeatherLocationMode ParseWeatherLocationMode(string? value)
{ {
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase) return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -1487,6 +1526,25 @@ public partial class MainWindow
PersistSettings(); PersistSettings();
} }
private void OnAppRenderModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressAppRenderModeSelectionEvents || AppRenderModeComboBox is null)
{
return;
}
var selectedMode = AppRenderingModeHelper.Normalize(
(AppRenderModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString());
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
{
return;
}
_selectedAppRenderMode = selectedMode;
PersistSettings();
}
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e) private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
{ {
if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null) if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null)
@@ -2640,7 +2698,9 @@ public partial class MainWindow
// --- AboutSettingsPage --- // --- AboutSettingsPage ---
internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!; internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!; internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!; internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!; internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!; internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;

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

View File

@@ -167,6 +167,8 @@ public partial class MainWindow : Window
private bool _weatherNoTlsRequests; private bool _weatherNoTlsRequests;
private bool _autoStartWithWindows; private bool _autoStartWithWindows;
private bool _suppressAutoStartToggleEvents; private bool _suppressAutoStartToggleEvents;
private bool _suppressAppRenderModeSelectionEvents;
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
private string _weatherSearchKeyword = string.Empty; private string _weatherSearchKeyword = string.Empty;
private bool _isWeatherSearchInProgress; private bool _isWeatherSearchInProgress;
private bool _isWeatherPreviewInProgress; private bool _isWeatherPreviewInProgress;
@@ -248,6 +250,7 @@ public partial class MainWindow : Window
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled; AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled; AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
} }
protected override void OnOpened(EventArgs e) protected override void OnOpened(EventArgs e)

View File

@@ -34,5 +34,27 @@
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>
</Border> </Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="AboutRenderModeSettingsExpander"
Header="Rendering Mode"
Description="Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software."
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Window" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ComboBox x:Name="AppRenderModeComboBox"
MinWidth="180"
HorizontalAlignment="Right">
<ComboBoxItem Content="Default" Tag="Default" />
<ComboBoxItem Content="Software" Tag="Software" />
<ComboBoxItem Content="angleEgl" Tag="AngleEgl" />
<ComboBoxItem Content="WGL" Tag="Wgl" />
<ComboBoxItem Content="Vulkan" Tag="Vulkan" />
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -189,7 +189,9 @@ public partial class SettingsWindow
// --- AboutSettingsPage --- // --- AboutSettingsPage ---
internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!; internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!; internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!; internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!; internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!; internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;

View File

@@ -128,6 +128,7 @@ public partial class SettingsWindow
snapshot.WeatherIconPackId = _weatherIconPackId; snapshot.WeatherIconPackId = _weatherIconPackId;
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests; snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
snapshot.AutoStartWithWindows = _autoStartWithWindows; snapshot.AutoStartWithWindows = _autoStartWithWindows;
snapshot.AppRenderMode = _selectedAppRenderMode;
snapshot.AutoCheckUpdates = _autoCheckUpdates; snapshot.AutoCheckUpdates = _autoCheckUpdates;
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates; snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable; snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -126,6 +127,15 @@ public partial class SettingsWindow
FontInfoTextBlock.Text = Lf("settings.about.font_format", "Font: {0}", AppFontName); FontInfoTextBlock.Text = Lf("settings.about.font_format", "Font: {0}", AppFontName);
AboutStartupSettingsExpander.Header = L("settings.about.startup_header", "Windows Startup"); AboutStartupSettingsExpander.Header = L("settings.about.startup_header", "Windows Startup");
AboutStartupSettingsExpander.Description = L("settings.about.startup_desc", "Launch the app automatically when signing in to Windows."); AboutStartupSettingsExpander.Description = L("settings.about.startup_desc", "Launch the app automatically when signing in to Windows.");
AboutRenderModeSettingsExpander.Header = L("settings.about.render_mode_header", "Rendering Mode");
AboutRenderModeSettingsExpander.Description = L(
"settings.about.render_mode_desc",
"Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.");
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Default, L("settings.about.render_mode.default", "Default"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Software, L("settings.about.render_mode.software", "Software"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
var placementItems = WallpaperPlacementComboBox.Items.OfType<ComboBoxItem>().ToList(); var placementItems = WallpaperPlacementComboBox.Items.OfType<ComboBoxItem>().ToList();
if (placementItems.Count >= 5) if (placementItems.Count >= 5)
@@ -142,6 +152,19 @@ public partial class SettingsWindow
RenderLauncherHiddenItemsList(); RenderLauncherHiddenItemsList();
} }
private void SetAppRenderModeComboItemContent(string tag, string content)
{
var item = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(candidate =>
string.Equals(candidate.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
item.Content = content;
}
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone) private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{ {
var offset = timeZone.GetUtcOffset(DateTime.UtcNow); var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -89,6 +89,33 @@ public partial class SettingsWindow
} }
} }
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
{
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
_suppressAppRenderModeSelectionEvents = true;
try
{
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
SelectAppRenderModeInUi(_selectedAppRenderMode);
}
finally
{
_suppressAppRenderModeSelectionEvents = false;
}
}
private void SelectAppRenderModeInUi(string renderMode)
{
var selectedItem = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
string.Equals(item.Tag?.ToString(), renderMode, StringComparison.OrdinalIgnoreCase));
AppRenderModeComboBox.SelectedItem = selectedItem
?? AppRenderModeComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static WeatherLocationMode ParseWeatherLocationMode(string? value) private static WeatherLocationMode ParseWeatherLocationMode(string? value)
{ {
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase) return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -319,6 +346,25 @@ public partial class SettingsWindow
PersistSettings(); PersistSettings();
} }
private void OnAppRenderModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressAppRenderModeSelectionEvents)
{
return;
}
var selectedMode = AppRenderingModeHelper.Normalize(
(AppRenderModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString());
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
{
return;
}
_selectedAppRenderMode = selectedMode;
PersistSettings();
}
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e) private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
{ {
if (_isWeatherSearchInProgress) if (_isWeatherSearchInProgress)

View File

@@ -120,6 +120,7 @@ public partial class SettingsWindow : Window
private bool _suppressGridInsetEvents; private bool _suppressGridInsetEvents;
private bool _suppressStatusBarSpacingEvents; private bool _suppressStatusBarSpacingEvents;
private bool _suppressAutoStartToggleEvents; private bool _suppressAutoStartToggleEvents;
private bool _suppressAppRenderModeSelectionEvents;
private bool _isUpdatingWallpaperPreviewLayout; private bool _isUpdatingWallpaperPreviewLayout;
private IBrush? _defaultDesktopBackground; private IBrush? _defaultDesktopBackground;
private Bitmap? _wallpaperBitmap; private Bitmap? _wallpaperBitmap;
@@ -140,6 +141,7 @@ public partial class SettingsWindow : Window
private string _statusBarSpacingMode = "Relaxed"; private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12; private int _statusBarCustomSpacingPercent = 12;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent; private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle; private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN"; private string _languageCode = "zh-CN";
private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch; private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch;
@@ -222,6 +224,7 @@ public partial class SettingsWindow : Window
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick; DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled; AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled; AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
Opened += OnWindowOpened; Opened += OnWindowOpened;
} }
@@ -260,6 +263,7 @@ public partial class SettingsWindow : Window
InitializeLocalization(snapshot.LanguageCode); InitializeLocalization(snapshot.LanguageCode);
InitializeWeatherSettings(snapshot); InitializeWeatherSettings(snapshot);
InitializeAutoStartWithWindowsSetting(snapshot); InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot); InitializeUpdateSettings(snapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot); InitializeLauncherVisibilitySettings(launcherSnapshot);
InitializeSettingsIcons(); InitializeSettingsIcons();