Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
e97db00999 0.5.4
项目重启优化。
2026-03-09 17:54:49 +08:00
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
30 changed files with 1682 additions and 311 deletions

3
.gitignore vendored
View File

@@ -488,3 +488,6 @@ nul
/_build_verify_plugin_tabs
/_build_verify_sample_plugin
/_build_verify_sample_plugin_capabilities
/_build_verify_plugin_page_host
/_build_verify_plugin_services
/_build_obj

View File

@@ -18,6 +18,9 @@ public interface IPluginContext
bool TryGetProperty<T>(string key, out T? value);
void RegisterService<TService>(TService service)
where TService : class;
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);

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();
}
if (Context is IAsyncDisposable asyncContext)
{
await asyncContext.DisposeAsync();
}
else if (Context is IDisposable disposableContext)
{
disposableContext.Dispose();
}
LoadContext.Unload();
GC.SuppressFinalize(this);
}

View File

@@ -125,14 +125,16 @@ public sealed class PluginLoader
IReadOnlyDictionary<string, object?>? properties)
{
PluginLoadContext? loadContext = null;
IPlugin? plugin = null;
PluginContext? context = null;
try
{
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
var pluginType = ResolvePluginType(assembly);
var plugin = CreatePluginInstance(pluginType);
var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
plugin = CreatePluginInstance(pluginType);
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
plugin.Initialize(context);
var settingsPages = context.GetSettingsPagesSnapshot();
@@ -153,6 +155,8 @@ public sealed class PluginLoader
}
catch (Exception ex)
{
DisposeInstance(plugin);
DisposeInstance(context);
loadContext?.Unload();
return PluginLoadResult.Failure(sourcePath, manifest, ex);
}
@@ -477,6 +481,33 @@ public sealed class PluginLoader
return plugin;
}
private static void DisposeInstance(object? instance)
{
if (instance is null)
{
return;
}
try
{
if (instance is IAsyncDisposable asyncDisposable)
{
asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult();
return;
}
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
catch (Exception disposeError)
{
System.Diagnostics.Debug.WriteLine(
$"[PluginLoader] Disposal of '{instance.GetType().FullName}' failed: {disposeError}");
}
}
private static Type[] GetLoadableTypes(Assembly assembly)
{
try
@@ -500,12 +531,17 @@ public sealed class PluginLoader
}
}
private sealed class PluginContext : IPluginContext
private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable
{
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, object> _registeredServices = [];
private readonly List<object> _serviceRegistrationOrder = [];
private readonly object _serviceGate = new();
private readonly IServiceProvider _hostServices;
private int _disposed;
public PluginContext(
PluginManifest manifest,
@@ -517,8 +553,12 @@ public sealed class PluginLoader
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
_hostServices = services;
Services = new PluginCompositeServiceProvider(this);
Properties = properties;
RegisterBuiltInService<IPluginContext>(this);
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
}
public PluginManifest Manifest { get; }
@@ -550,9 +590,16 @@ public sealed class PluginLoader
return false;
}
public void RegisterService<TService>(TService service)
where TService : class
{
RegisterServiceCore(typeof(TService), service, allowOverride: false);
}
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
ThrowIfDisposed();
if (!_settingsPages.TryAdd(registration.Id, registration))
{
@@ -564,6 +611,7 @@ public sealed class PluginLoader
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
ThrowIfDisposed();
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
{
@@ -574,6 +622,7 @@ public sealed class PluginLoader
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
{
ThrowIfDisposed();
return _settingsPages.Values
.OrderBy(page => page.SortOrder)
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
@@ -582,11 +631,270 @@ public sealed class PluginLoader
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
{
ThrowIfDisposed();
return _desktopComponents.Values
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
internal object? ResolveService(Type serviceType)
{
if (Volatile.Read(ref _disposed) != 0)
{
return null;
}
if (serviceType == typeof(IServiceProvider))
{
return Services;
}
lock (_serviceGate)
{
if (_registeredServices.TryGetValue(serviceType, out var service))
{
return service;
}
foreach (var registeredService in _registeredServices.Values)
{
if (serviceType.IsInstanceOfType(registeredService))
{
return registeredService;
}
}
}
return _hostServices.GetService(serviceType);
}
private void RegisterBuiltInService<TService>(TService service)
where TService : class
{
RegisterServiceCore(typeof(TService), service, allowOverride: true);
}
public void Dispose()
{
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
object[] services;
lock (_serviceGate)
{
services = _serviceRegistrationOrder.ToArray();
_registeredServices.Clear();
_serviceRegistrationOrder.Clear();
}
_settingsPages.Clear();
_desktopComponents.Clear();
var disposedServices = new HashSet<object>(ReferenceEqualityComparer.Instance);
for (var i = services.Length - 1; i >= 0; i--)
{
var service = services[i];
if (ReferenceEquals(service, this) || !disposedServices.Add(service))
{
continue;
}
if (service is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (service is IDisposable disposable)
{
disposable.Dispose();
}
}
}
private void RegisterServiceCore(Type serviceType, object service, bool allowOverride)
{
ArgumentNullException.ThrowIfNull(serviceType);
ArgumentNullException.ThrowIfNull(service);
ThrowIfDisposed();
if (!serviceType.IsInstanceOfType(service))
{
throw new InvalidOperationException(
$"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'.");
}
lock (_serviceGate)
{
if (!allowOverride && _registeredServices.ContainsKey(serviceType))
{
throw new InvalidOperationException(
$"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'.");
}
_registeredServices[serviceType] = service;
_serviceRegistrationOrder.Add(service);
}
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(PluginContext));
}
}
}
private sealed class PluginCompositeServiceProvider : IServiceProvider
{
private readonly PluginContext _context;
public PluginCompositeServiceProvider(PluginContext context)
{
_context = context;
}
public object? GetService(Type serviceType)
{
ArgumentNullException.ThrowIfNull(serviceType);
return _context.ResolveService(serviceType);
}
}
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
{
private readonly Dictionary<Type, List<Subscription>> _subscriptions = [];
private readonly object _gate = new();
private int _disposed;
public IDisposable Subscribe<TMessage>(Action<TMessage> handler)
{
ArgumentNullException.ThrowIfNull(handler);
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(PluginMessageBus));
}
var subscription = new Subscription(this, typeof(TMessage), message => handler((TMessage)message!));
lock (_gate)
{
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
{
handlers = [];
_subscriptions[subscription.MessageType] = handlers;
}
handlers.Add(subscription);
}
return subscription;
}
public void Publish<TMessage>(TMessage message)
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
Subscription[] handlers;
lock (_gate)
{
if (!_subscriptions.TryGetValue(typeof(TMessage), out var subscriptions) || subscriptions.Count == 0)
{
return;
}
handlers = subscriptions.ToArray();
}
foreach (var handler in handlers)
{
try
{
handler.Invoke(message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"[PluginMessageBus] Handler for '{typeof(TMessage).FullName}' failed: {ex}");
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_subscriptions.Clear();
}
}
private void Unsubscribe(Subscription subscription)
{
lock (_gate)
{
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
{
return;
}
handlers.Remove(subscription);
if (handlers.Count == 0)
{
_subscriptions.Remove(subscription.MessageType);
}
}
}
private sealed class Subscription : IDisposable
{
private readonly PluginMessageBus _owner;
private int _disposed;
public Subscription(PluginMessageBus owner, Type messageType, Action<object?> handler)
{
_owner = owner;
MessageType = messageType;
Handler = handler;
}
public Type MessageType { get; }
public Action<object?> Handler { get; }
public void Invoke(object? message)
{
if (_disposed != 0)
{
return;
}
Handler(message);
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_owner.Unsubscribe(this);
}
}
}
private sealed class NullServiceProvider : IServiceProvider

View File

@@ -5,39 +5,49 @@ namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginHeartbeatService? _heartbeatService;
private SamplePluginRuntimeStateService? _stateService;
private SamplePluginClockService? _clockService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var hostName = context.TryGetProperty<string>("HostApplicationName", out var configuredHostName) &&
!string.IsNullOrWhiteSpace(configuredHostName)
? configuredHostName
: "UnknownHost";
var hostName = GetHostProperty(context, "HostApplicationName", "UnknownHost");
var hostVersion = GetHostProperty(context, "HostVersion", "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, "PluginSdkApiVersion", "UnknownApiVersion");
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
var version = context.Manifest.Version ?? "dev";
SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory);
_stateService = new SamplePluginRuntimeStateService(
context.Manifest,
context.PluginDirectory,
context.DataDirectory,
hostName,
hostVersion,
sdkApiVersion,
messageBus);
context.RegisterService(_stateService);
var message =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version}).";
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus);
context.RegisterService(_clockService);
_stateService.AttachClockService(_clockService);
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
var initMessage =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
try
{
File.AppendAllText(
Path.Combine(context.DataDirectory, "sample-plugin.log"),
message + Environment.NewLine);
SamplePluginRuntimeStatus.MarkBackendReady(
$"Plugin entry initialized successfully. Host: {hostName}; Version: {version}");
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady($"Initialization log written to {logPath}.");
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
_stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
throw;
}
_heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory);
_heartbeatService.Start();
_clockService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
@@ -60,7 +70,15 @@ public sealed class SamplePlugin : PluginBase, IDisposable
public void Dispose()
{
_heartbeatService?.Dispose();
_heartbeatService = null;
_clockService?.Dispose();
_clockService = null;
_stateService = null;
}
private static string GetHostProperty(IPluginContext context, string key, string fallback)
{
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: fallback;
}
}

View File

@@ -1,6 +1,8 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
@@ -19,75 +21,127 @@ internal sealed record SamplePluginStatusEntry(
string Detail,
DateTimeOffset UpdatedAt);
internal static class SamplePluginRuntimeStatus
internal sealed record SamplePluginCapabilityItem(
string Title,
string Detail);
internal sealed record SamplePluginRuntimeSnapshot(
PluginManifest Manifest,
string PluginDirectory,
string DataDirectory,
string HostApplicationName,
string HostVersion,
string SdkApiVersion,
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
bool HasPlacedComponent,
int PlacedCount,
int PreviewCount,
IReadOnlyList<string> PlacementIds,
string? LastComponentId,
double LastCellSize,
DateTimeOffset? ServiceClockTime);
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
internal sealed record SamplePluginStateChangedMessage(string Reason);
internal sealed record SamplePluginComponentInstance(
string ComponentId,
string? PlacementId,
double CellSize)
{
private static readonly object Gate = new();
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
}
private static SamplePluginStatusEntry _frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Frontend surfaces have not been created yet.");
internal sealed class SamplePluginRuntimeStateService
{
private readonly object _gate = new();
private readonly IPluginMessageBus _messageBus;
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
new(StringComparer.OrdinalIgnoreCase);
private static SamplePluginStatusEntry _component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
private readonly PluginManifest _manifest;
private readonly string _pluginDirectory;
private readonly string _dataDirectory;
private readonly string _hostApplicationName;
private readonly string _hostVersion;
private readonly string _sdkApiVersion;
private static SamplePluginStatusEntry _backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Pending,
"Pending",
"Plugin initialization has not finished yet.");
private SamplePluginStatusEntry _frontend;
private SamplePluginStatusEntry _component;
private SamplePluginStatusEntry _backend;
private SamplePluginStatusEntry _service;
private string? _lastComponentId;
private double _lastCellSize;
private DateTimeOffset? _serviceClockTime;
private static SamplePluginStatusEntry _service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service has not started yet.");
public static void Reset(string hostName, string version, string dataDirectory)
public SamplePluginRuntimeStateService(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
string hostApplicationName,
string hostVersion,
string sdkApiVersion,
IPluginMessageBus messageBus)
{
lock (Gate)
{
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Waiting for the settings page or widget surface to render.");
_manifest = manifest;
_pluginDirectory = pluginDirectory;
_dataDirectory = dataDirectory;
_hostApplicationName = hostApplicationName;
_hostVersion = hostVersion;
_sdkApiVersion = sdkApiVersion;
_messageBus = messageBus;
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Waiting for a plugin UI surface to connect.");
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
$"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}");
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"No component instance has been created yet.");
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service is starting.");
}
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Pending,
"Pending",
"Plugin initialization is in progress.");
_service = CreateEntry(
"service",
"Clock Service",
SamplePluginHealthState.Pending,
"Pending",
"Clock service is not attached yet.");
}
public static void MarkFrontendReady(string detail)
public void AttachClockService(SamplePluginClockService clockService)
{
lock (Gate)
ArgumentNullException.ThrowIfNull(clockService);
lock (_gate)
{
_serviceClockTime = clockService.CurrentTime;
_service = CreateEntry(
"service",
"Clock Service",
SamplePluginHealthState.Pending,
"Attached",
"Clock service was attached and is waiting for the first tick.");
}
PublishStateChanged("Clock service attached");
}
public void MarkFrontendReady(string detail)
{
lock (_gate)
{
_frontend = CreateEntry(
"frontend",
@@ -96,85 +150,220 @@ internal static class SamplePluginRuntimeStatus
"Healthy",
detail);
}
PublishStateChanged("Frontend updated");
}
public static void MarkComponentCreated(string detail)
public void MarkBackendReady(string detail)
{
lock (Gate)
lock (_gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
}
PublishStateChanged("Backend updated");
}
public void MarkBackendFaulted(string detail)
{
lock (_gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
PublishStateChanged("Backend faulted");
}
public void MarkClockServiceTick(DateTimeOffset currentTime)
{
lock (_gate)
{
_serviceClockTime = currentTime;
_service = CreateEntry(
"service",
"Clock Service",
SamplePluginHealthState.Healthy,
"Healthy",
$"Clock service is running. Current service time: {currentTime.LocalDateTime:HH:mm:ss}");
}
PublishStateChanged("Clock service tick");
}
public void MarkClockServiceFaulted(string detail)
{
lock (_gate)
{
_service = CreateEntry(
"service",
"Clock Service",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
PublishStateChanged("Clock service faulted");
}
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
{
var instanceId = Guid.NewGuid().ToString("N");
lock (_gate)
{
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
_lastComponentId = componentId;
_lastCellSize = cellSize;
UpdateComponentStatusNoLock();
}
PublishStateChanged("Component attached");
return instanceId;
}
public void UnregisterComponentInstance(string instanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
var removed = false;
lock (_gate)
{
removed = _componentInstances.Remove(instanceId);
if (removed)
{
UpdateComponentStatusNoLock();
}
}
if (removed)
{
PublishStateChanged("Component detached");
}
}
public SamplePluginRuntimeSnapshot GetSnapshot()
{
lock (_gate)
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
return new SamplePluginRuntimeSnapshot(
_manifest,
_pluginDirectory,
_dataDirectory,
_hostApplicationName,
_hostVersion,
_sdkApiVersion,
[_frontend, _component, _backend, _service],
placementIds.Length > 0,
placementIds.Length,
previewCount,
placementIds,
_lastComponentId,
_lastCellSize,
_serviceClockTime);
}
}
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
IPluginContext context,
bool hasStateService,
bool hasClockService,
bool hasMessageBus)
{
ArgumentNullException.ThrowIfNull(context);
var propertyNames = context.Properties.Count == 0
? "(none)"
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
return
[
new SamplePluginCapabilityItem(
"IPluginContext.Manifest",
$"Readable. Current plugin id: {context.Manifest.Id}; version: {context.Manifest.Version ?? "dev"}."),
new SamplePluginCapabilityItem(
"IPluginContext.PluginDirectory / DataDirectory",
$"Readable. Plugin directory: {context.PluginDirectory}; data directory: {context.DataDirectory}."),
new SamplePluginCapabilityItem(
"IPluginContext.Properties",
$"Readable. Host properties currently exposed: {propertyNames}."),
new SamplePluginCapabilityItem(
"IPluginContext.GetService<T>()",
$"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."),
new SamplePluginCapabilityItem(
"IPluginContext.RegisterService<TService>()",
"Callable during plugin initialization. This plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container."),
new SamplePluginCapabilityItem(
"Plugin communication bus",
"This plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces."),
new SamplePluginCapabilityItem(
"PluginDesktopComponentContext",
"Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.")
];
}
private void UpdateComponentStatusNoLock()
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
if (placementIds.Length > 0)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Created",
detail);
"Placed",
$"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}");
return;
}
}
public static void MarkBackendReady(string detail)
{
lock (Gate)
if (previewCount > 0)
{
_backend = CreateEntry(
"backend",
"Backend",
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
"Preview",
$"Preview instances: {previewCount}; no placed desktop instance is active yet.");
return;
}
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"No component instance is active.");
}
public static void MarkBackendFaulted(string detail)
private void PublishStateChanged(string reason)
{
lock (Gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static void MarkServiceHeartbeat(DateTimeOffset timestamp)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Healthy,
"Healthy",
$"Heartbeat service is running. Last heartbeat: {timestamp.LocalDateTime:HH:mm:ss}");
}
}
public static void MarkServiceFaulted(string detail)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static IReadOnlyList<SamplePluginStatusEntry> GetSnapshot()
{
lock (Gate)
{
return
[
_frontend,
_component,
_backend,
_service
];
}
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
}
private static SamplePluginStatusEntry CreateEntry(
@@ -194,23 +383,42 @@ internal static class SamplePluginRuntimeStatus
}
}
internal sealed class SamplePluginHeartbeatService : IDisposable
internal sealed class SamplePluginClockService : IDisposable
{
private readonly string _heartbeatFilePath;
private readonly object _gate = new();
private readonly string _clockStateFilePath;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly IPluginMessageBus _messageBus;
private readonly Timer _timer;
private DateTimeOffset _currentTime = DateTimeOffset.Now;
private int _disposed;
public SamplePluginHeartbeatService(string dataDirectory)
public SamplePluginClockService(
string dataDirectory,
SamplePluginRuntimeStateService stateService,
IPluginMessageBus messageBus)
{
Directory.CreateDirectory(dataDirectory);
_heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt");
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
_stateService = stateService;
_messageBus = messageBus;
_timer = new Timer(OnTimerTick);
}
public DateTimeOffset CurrentTime
{
get
{
lock (_gate)
{
return _currentTime;
}
}
}
public void Start()
{
PublishHeartbeat();
_timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
PublishTick();
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
public void Dispose()
@@ -225,10 +433,10 @@ internal sealed class SamplePluginHeartbeatService : IDisposable
private void OnTimerTick(object? state)
{
PublishHeartbeat();
PublishTick();
}
private void PublishHeartbeat()
private void PublishTick()
{
if (Volatile.Read(ref _disposed) != 0)
{
@@ -236,16 +444,22 @@ internal sealed class SamplePluginHeartbeatService : IDisposable
}
var now = DateTimeOffset.Now;
lock (_gate)
{
_currentTime = now;
}
try
{
File.WriteAllText(
_heartbeatFilePath,
_clockStateFilePath,
now.ToString("O", CultureInfo.InvariantCulture));
SamplePluginRuntimeStatus.MarkServiceHeartbeat(now);
_stateService.MarkClockServiceTick(now);
_messageBus.Publish(new SamplePluginClockTickMessage(now));
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkServiceFaulted($"Heartbeat write failed: {ex.Message}");
_stateService.MarkClockServiceFaulted($"Clock state write failed: {ex.Message}");
}
}
}

View File

@@ -9,31 +9,27 @@ namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginSettingsView : UserControl
{
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly IPluginContext _context;
private readonly TextBlock _summaryTextBlock;
private readonly StackPanel _statusPanel;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
private readonly List<IDisposable> _subscriptions = [];
public SamplePluginSettingsView(IPluginContext context)
{
_context = context;
_summaryTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 10
};
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully.");
_stateService.MarkFrontendReady("Settings page is connected to plugin services and communication.");
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
@@ -60,51 +56,110 @@ internal sealed class SamplePluginSettingsView : UserControl
{
new TextBlock
{
Text = "Sample Plugin Runtime Status",
Text = "Sample Plugin Capability Inspector",
FontSize = 22,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
_summaryTextBlock,
new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = _statusPanel
}
CreateSection("Plugin Info", _pluginInfoPanel),
CreateSection("Accessible Capabilities", _capabilityPanel),
CreateSection("Live Runtime Status", _statusPanel)
}
}
};
RefreshStatuses();
RefreshView();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshStatuses();
_refreshTimer.Start();
SubscribeToPluginBus();
RefreshView();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_refreshTimer.Stop();
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
private void SubscribeToPluginBus()
{
RefreshStatuses();
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
}
private void RefreshStatuses()
private void RefreshView()
{
_summaryTextBlock.Text =
$"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}";
var snapshot = _stateService.GetSnapshot();
RefreshPluginInfo(snapshot);
RefreshCapabilities();
RefreshStatuses(snapshot);
}
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
{
_pluginInfoPanel.Children.Clear();
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Name", snapshot.Manifest.Name));
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Id", snapshot.Manifest.Id));
_pluginInfoPanel.Children.Add(CreateInfoLine("Version", snapshot.Manifest.Version ?? "dev"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Author", snapshot.Manifest.Author ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Description", snapshot.Manifest.Description ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Directory", snapshot.PluginDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine("Data Directory", snapshot.DataDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Application", snapshot.HostApplicationName));
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Version", snapshot.HostVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine("SDK API Version", snapshot.SdkApiVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine("State Service Resolved", (_context.GetService<SamplePluginRuntimeStateService>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService<SamplePluginClockService>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService<IPluginMessageBus>() is not null).ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Component Placed", snapshot.HasPlacedComponent ? "Yes" : "No"));
_pluginInfoPanel.Children.Add(CreateInfoLine("Placed Count", snapshot.PlacedCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine("Preview Count", snapshot.PreviewCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Placement Ids",
snapshot.PlacementIds.Count == 0 ? "(none)" : string.Join(", ", snapshot.PlacementIds)));
_pluginInfoPanel.Children.Add(CreateInfoLine("Last Component Id", snapshot.LastComponentId ?? "(none)"));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Last Cell Size",
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : "(unknown)"));
_pluginInfoPanel.Children.Add(CreateInfoLine(
"Clock Service Time",
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
}
private void RefreshCapabilities()
{
var capabilities = _stateService.GetCapabilities(
_context,
_context.GetService<SamplePluginRuntimeStateService>() is not null,
_context.GetService<SamplePluginClockService>() is not null,
_context.GetService<IPluginMessageBus>() is not null);
_capabilityPanel.Children.Clear();
foreach (var capability in capabilities)
{
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
}
}
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
{
_statusPanel.Children.Clear();
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
@@ -119,35 +174,7 @@ internal sealed class SamplePluginSettingsView : UserControl
Spacing = 4,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
}
}
},
CreateStatusHeader(entry, palette),
new TextBlock
{
Text = entry.Detail,
@@ -162,13 +189,135 @@ internal sealed class SamplePluginSettingsView : UserControl
}
}
});
var row = (Grid)((StackPanel)((Border)_statusPanel.Children[^1]).Child!).Children[0];
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
}
}
private Border CreateSection(string title, Control content)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = title,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
content
}
}
};
}
private Control CreateInfoLine(string label, string value)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("180,*"),
ColumnSpacing = 10
};
var labelText = new TextBlock
{
Text = label,
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
};
var valueText = new TextBlock
{
Text = value,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
};
grid.Children.Add(labelText);
grid.Children.Add(valueText);
Grid.SetColumn(valueText, 1);
return grid;
}
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = item.Title,
Foreground = Brushes.White,
FontWeight = FontWeight.SemiBold
},
new TextBlock
{
Text = item.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
private static Control CreateStatusHeader(
SamplePluginStatusEntry entry,
(Color Background, Color Border, Color Dot) palette)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8
};
var dot = new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
};
var title = new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
};
var summary = new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
};
grid.Children.Add(dot);
grid.Children.Add(title);
grid.Children.Add(summary);
Grid.SetColumn(title, 1);
Grid.SetColumn(summary, 2);
return grid;
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch

View File

@@ -9,35 +9,50 @@ namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginStatusClockWidget : Border
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly PluginDesktopComponentContext _context;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly TextBlock _timeTextBlock;
private readonly TextBlock _titleTextBlock;
private readonly TextBlock _subtitleTextBlock;
private readonly StackPanel _statusPanel;
private readonly Border _statusHost;
private readonly List<IDisposable> _subscriptions = [];
private string? _instanceId;
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
{
_context = context;
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
_timeTextBlock = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Left
};
_titleTextBlock = new TextBlock
_subtitleTextBlock = new TextBlock
{
Text = "Plugin Status",
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
HorizontalAlignment = HorizontalAlignment.Left
HorizontalAlignment = HorizontalAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 8
};
_statusHost = new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
Child = _statusPanel
};
Background = new LinearGradientBrush
{
@@ -67,55 +82,59 @@ internal sealed class SamplePluginStatusClockWidget : Border
Children =
{
_timeTextBlock,
_titleTextBlock
_subtitleTextBlock
}
},
new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(12),
Child = _statusPanel
}
_statusHost
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
var placementText = string.IsNullOrWhiteSpace(context.PlacementId)
? "Preview instance created."
: $"Widget created for placement {context.PlacementId}.";
SamplePluginRuntimeStatus.MarkFrontendReady("Widget frontend surface rendered successfully.");
SamplePluginRuntimeStatus.MarkComponentCreated($"{placementText} Baseline footprint: 4x4.");
RefreshClock();
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshClock();
if (string.IsNullOrWhiteSpace(_instanceId))
{
_instanceId = _stateService.RegisterComponentInstance(
_context.ComponentId,
_context.PlacementId,
_context.CellSize);
}
_stateService.MarkFrontendReady("Widget surface is connected to plugin services and communication.");
SubscribeToPluginBus();
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
private void OnTimerTick(object? sender, EventArgs e)
{
RefreshClock();
RefreshStatusPanel();
_subscriptions.Clear();
if (string.IsNullOrWhiteSpace(_instanceId))
{
return;
}
_stateService.UnregisterComponentInstance(_instanceId);
_instanceId = null;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -124,24 +143,49 @@ internal sealed class SamplePluginStatusClockWidget : Border
RefreshStatusPanel();
}
private void RefreshClock()
private void SubscribeToPluginBus()
{
_timeTextBlock.Text = DateTime.Now.ToString("HH:mm:ss");
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(() =>
{
UpdateSubtitle();
RefreshStatusPanel();
})));
}
private void RefreshClock(DateTimeOffset currentTime)
{
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
}
private void UpdateSubtitle()
{
var snapshot = _stateService.GetSnapshot();
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
? $"Preview surface | placed: {snapshot.PlacedCount}"
: $"Placement {_context.PlacementId} | placed: {snapshot.PlacedCount}";
}
private void RefreshStatusPanel()
{
_statusPanel.Children.Clear();
var snapshot = _stateService.GetSnapshot();
var basis = GetLayoutBasis();
var titleSize = Math.Clamp(basis * 0.072, 11, 16);
var detailSize = Math.Clamp(basis * 0.055, 10, 13);
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
var summaryText = $"{entry.Summary} - {entry.UpdatedAt.LocalDateTime:HH:mm:ss}";
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
@@ -151,6 +195,7 @@ internal sealed class SamplePluginStatusClockWidget : Border
Padding = new Thickness(10, 8),
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
@@ -173,12 +218,19 @@ internal sealed class SamplePluginStatusClockWidget : Border
},
new TextBlock
{
Text = summaryText,
Text = entry.Summary,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right,
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Detail,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
TextWrapping = TextWrapping.Wrap
}
}
}
@@ -187,6 +239,8 @@ internal sealed class SamplePluginStatusClockWidget : Border
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
Grid.SetColumnSpan(row.Children[3], 3);
Grid.SetRow(row.Children[3], 1);
}
}
@@ -196,7 +250,10 @@ internal sealed class SamplePluginStatusClockWidget : Border
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
_titleTextBlock.FontSize = Math.Clamp(basis * 0.07, 12, 18);
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
}
private double GetLayoutBasis()

View File

@@ -101,42 +101,12 @@ public partial class App : Application
return;
}
if (TryStartCurrentProcess())
if (AppRestartService.TryRestartCurrentProcess())
{
desktop.Shutdown();
}
}
private static bool TryStartCurrentProcess()
{
try
{
var args = Environment.GetCommandLineArgs();
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
{
return false;
}
var startInfo = new ProcessStartInfo
{
FileName = args[0],
UseShellExecute = false
};
for (var i = 1; i < args.Length; i++)
{
startInfo.ArgumentList.Add(args[i]);
}
Process.Start(startInfo);
return true;
}
catch
{
return false;
}
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove

View File

@@ -237,6 +237,18 @@
"settings.about.startup_header": "Windows Startup",
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
"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.about.render_mode.unknown": "Unknown",
"settings.about.render_mode.current_label": "Current actual backend",
"settings.about.render_mode.current_format": "Current backend: {0}",
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
"settings.footer": "LanMountainDesktop Settings",
"filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files",

View File

@@ -237,6 +237,18 @@
"settings.about.startup_header": "Windows 自启动",
"settings.about.startup_desc": "在登录 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.about.render_mode.unknown": "未知",
"settings.about.render_mode.current_label": "当前实际渲染后端",
"settings.about.render_mode.current_format": "当前后端:{0}",
"settings.about.render_mode.impl_format": "运行时实现:{0}",
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",

View File

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

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
using System;
namespace LanMountainDesktop;
@@ -10,14 +11,42 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseDesktopWebView()
.WithInterFont()
.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,73 @@
using System;
using System.Reflection;
using Avalonia;
using Avalonia.Platform;
namespace LanMountainDesktop.Services;
public readonly record struct AppRenderBackendInfo(
string ActualBackend,
string? ImplementationTypeName);
public static class AppRenderBackendDiagnostics
{
public const string Unknown = "Unknown";
public static AppRenderBackendInfo Detect()
{
var platformGraphics = GetPlatformGraphics();
var implementationTypeName = platformGraphics?.GetType().FullName;
var actualBackend = DetectBackendFromImplementationType(implementationTypeName, platformGraphics is null);
return new AppRenderBackendInfo(actualBackend, implementationTypeName);
}
private static object? GetPlatformGraphics()
{
var currentResolver = typeof(AvaloniaLocator)
.GetProperty("Current", BindingFlags.Public | BindingFlags.Static)
?.GetValue(null);
var getServiceMethod = currentResolver?
.GetType()
.GetMethod(
"GetService",
BindingFlags.Public | BindingFlags.Instance,
binder: null,
types: [typeof(Type)],
modifiers: null);
return getServiceMethod?.Invoke(currentResolver, [typeof(IPlatformGraphics)]);
}
private static string DetectBackendFromImplementationType(string? implementationTypeName, bool isSoftwareFallback)
{
if (isSoftwareFallback)
{
return AppRenderingModeHelper.Software;
}
if (string.IsNullOrWhiteSpace(implementationTypeName))
{
return Unknown;
}
if (implementationTypeName.Contains("Vulkan", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.Vulkan;
}
if (implementationTypeName.Contains("Wgl", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.Wgl;
}
if (implementationTypeName.Contains("Angle", StringComparison.OrdinalIgnoreCase) ||
implementationTypeName.Contains("Egl", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.AngleEgl;
}
return Unknown;
}
}

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

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
namespace LanMountainDesktop.Services;
public static class AppRestartService
{
public static bool TryRestartCurrentProcess()
{
try
{
var startInfo = CreateRestartStartInfo();
if (startInfo is null)
{
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
return false;
}
Process.Start(startInfo);
return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
return false;
}
}
public static ProcessStartInfo? CreateRestartStartInfo(
string[]? commandLineArgs = null,
string? processPath = null,
string? entryAssemblyLocation = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingPath(
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
if (IsDotnetHost(resolvedProcessPath))
{
return CreateDotnetStartInfo(
resolvedProcessPath!,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
{
return CreateExecutableStartInfo(
resolvedProcessPath,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
{
return CreateDotnetStartInfo(
"dotnet",
resolvedEntryAssemblyPath,
args);
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
return startInfo;
}
private static ProcessStartInfo? CreateDotnetStartInfo(
string dotnetHostPath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
{
return null;
}
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
return startInfo;
}
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
startInfo.ArgumentList.Add(commandLineArgs[i]);
}
}
private static string? NormalizeExistingPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return File.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static bool IsDotnetHost(string? processPath)
{
if (string.IsNullOrWhiteSpace(processPath))
{
return false;
}
var fileName = Path.GetFileName(processPath);
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
{
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
? entryAssemblyPath
: launchPath;
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
}
}

View File

@@ -280,9 +280,22 @@ public sealed class PluginRuntimeService : IDisposable
{
var options = new PluginLoaderOptions();
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(Application).Assembly);
AddSharedAssembly(options, typeof(Control).Assembly);
AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly);
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var assemblyName = assembly.GetName().Name;
if (string.IsNullOrWhiteSpace(assemblyName))
{
continue;
}
if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase))
{
AddSharedAssembly(options, assembly);
}
}
return options;
}

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using FluentIcons.Avalonia;
using FluentIcons.Common;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -315,6 +317,16 @@ public partial class MainWindow
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"));
UpdateCurrentRenderBackendStatus();
if (WallpaperPlacementComboBox?.ItemCount >= 5)
{
@@ -341,6 +353,19 @@ public partial class MainWindow
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)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -0,0 +1,42 @@
using System;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private void UpdateCurrentRenderBackendStatus()
{
var backendInfo = AppRenderBackendDiagnostics.Detect();
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
CurrentRenderBackendLabelTextBlock.Text = L(
"settings.about.render_mode.current_label",
"Current actual backend");
CurrentRenderBackendValueTextBlock.Text = Lf(
"settings.about.render_mode.current_format",
"Current backend: {0}",
localizedBackend);
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
? L(
"settings.about.render_mode.impl_unavailable",
"Runtime implementation is unavailable.")
: Lf(
"settings.about.render_mode.impl_format",
"Runtime implementation: {0}",
backendInfo.ImplementationTypeName);
}
private string GetLocalizedRenderBackendName(string renderBackend)
{
return renderBackend switch
{
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.unknown", "Unknown")
};
}
}

View File

@@ -978,6 +978,7 @@ public partial class MainWindow
InitializeWeatherSettings(snapshot);
_ = _componentSettingsService.Load();
InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot);
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot);
@@ -1040,6 +1041,7 @@ public partial class MainWindow
snapshot.WeatherIconPackId = _weatherIconPackId;
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
snapshot.AutoStartWithWindows = _autoStartWithWindows;
snapshot.AppRenderMode = _selectedAppRenderMode;
snapshot.AutoCheckUpdates = _autoCheckUpdates;
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
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)
{
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -1487,6 +1526,25 @@ public partial class MainWindow
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)
{
if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null)
@@ -2640,7 +2698,12 @@ public partial class MainWindow
// --- AboutSettingsPage ---
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 AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;

View File

@@ -104,7 +104,7 @@
ClipToBounds="True"
BorderThickness="0"
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
<Grid x:Name="SettingsContentPagesHost">
<Grid>
<Grid x:Name="DesktopPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
@@ -439,7 +439,7 @@
Padding="0,0,16,0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>
<Grid x:Name="SettingsContentPagesHost">
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />

View File

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

View File

@@ -34,5 +34,45 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</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>
<StackPanel Spacing="4"
Margin="0,4,0,0">
<TextBlock x:Name="CurrentRenderBackendLabelTextBlock"
Text="Current actual backend"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CurrentRenderBackendValueTextBlock"
Text="Current backend: Software"
FontSize="13"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CurrentRenderBackendImplementationTextBlock"
Text="Runtime implementation is unavailable."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
<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>
</UserControl>

View File

@@ -189,7 +189,12 @@ public partial class SettingsWindow
// --- AboutSettingsPage ---
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 AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;

View File

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

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -126,6 +127,16 @@ public partial class SettingsWindow
FontInfoTextBlock.Text = Lf("settings.about.font_format", "Font: {0}", AppFontName);
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.");
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"));
UpdateCurrentRenderBackendStatus();
var placementItems = WallpaperPlacementComboBox.Items.OfType<ComboBoxItem>().ToList();
if (placementItems.Count >= 5)
@@ -142,6 +153,19 @@ public partial class SettingsWindow
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)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -0,0 +1,42 @@
using System;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private void UpdateCurrentRenderBackendStatus()
{
var backendInfo = AppRenderBackendDiagnostics.Detect();
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
CurrentRenderBackendLabelTextBlock.Text = L(
"settings.about.render_mode.current_label",
"Current actual backend");
CurrentRenderBackendValueTextBlock.Text = Lf(
"settings.about.render_mode.current_format",
"Current backend: {0}",
localizedBackend);
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
? L(
"settings.about.render_mode.impl_unavailable",
"Runtime implementation is unavailable.")
: Lf(
"settings.about.render_mode.impl_format",
"Runtime implementation: {0}",
backendInfo.ImplementationTypeName);
}
private string GetLocalizedRenderBackendName(string renderBackend)
{
return renderBackend switch
{
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.unknown", "Unknown")
};
}
}

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)
{
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -319,6 +346,25 @@ public partial class SettingsWindow
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)
{
if (_isWeatherSearchInProgress)

View File

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