mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb6b01236 | ||
|
|
103b215e35 | ||
|
|
cab35f4c22 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -484,3 +484,10 @@ nul
|
|||||||
/publish-test
|
/publish-test
|
||||||
/_build_verify
|
/_build_verify
|
||||||
/_build_verify_tray
|
/_build_verify_tray
|
||||||
|
/_build_verify_plugin
|
||||||
|
/_build_verify_plugin_tabs
|
||||||
|
/_build_verify_sample_plugin
|
||||||
|
/_build_verify_sample_plugin_capabilities
|
||||||
|
/_build_verify_plugin_page_host
|
||||||
|
/_build_verify_plugin_services
|
||||||
|
/_build_obj
|
||||||
|
|||||||
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPlugin
|
||||||
|
{
|
||||||
|
void Initialize(IPluginContext context);
|
||||||
|
}
|
||||||
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginContext
|
||||||
|
{
|
||||||
|
PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
string PluginDirectory { get; }
|
||||||
|
|
||||||
|
string DataDirectory { get; }
|
||||||
|
|
||||||
|
IServiceProvider Services { get; }
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
T? GetService<T>();
|
||||||
|
|
||||||
|
bool TryGetProperty<T>(string key, out T? value);
|
||||||
|
|
||||||
|
void RegisterService<TService>(TService service)
|
||||||
|
where TService : class;
|
||||||
|
|
||||||
|
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||||
|
|
||||||
|
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||||
|
}
|
||||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginMessageBus
|
||||||
|
{
|
||||||
|
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||||
|
|
||||||
|
void Publish<TMessage>(TMessage message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
83
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
83
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
internal LoadedPlugin(
|
||||||
|
PluginManifest manifest,
|
||||||
|
string sourcePath,
|
||||||
|
string assemblyPath,
|
||||||
|
Assembly assembly,
|
||||||
|
IPlugin plugin,
|
||||||
|
IPluginContext context,
|
||||||
|
IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
|
||||||
|
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||||
|
PluginLoadContext loadContext)
|
||||||
|
{
|
||||||
|
Manifest = manifest;
|
||||||
|
SourcePath = sourcePath;
|
||||||
|
AssemblyPath = assemblyPath;
|
||||||
|
Assembly = assembly;
|
||||||
|
Plugin = plugin;
|
||||||
|
Context = context;
|
||||||
|
SettingsPages = settingsPages;
|
||||||
|
DesktopComponents = desktopComponents;
|
||||||
|
LoadContext = loadContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
public string SourcePath { get; }
|
||||||
|
|
||||||
|
public string AssemblyPath { get; }
|
||||||
|
|
||||||
|
public Assembly Assembly { get; }
|
||||||
|
|
||||||
|
public IPlugin Plugin { get; }
|
||||||
|
|
||||||
|
public IPluginContext Context { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginSettingsPageRegistration> SettingsPages { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||||
|
|
||||||
|
public PluginLoadContext LoadContext { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plugin is IAsyncDisposable asyncDisposable)
|
||||||
|
{
|
||||||
|
await asyncDisposable.DisposeAsync();
|
||||||
|
}
|
||||||
|
else if (Plugin is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Context is IAsyncDisposable asyncContext)
|
||||||
|
{
|
||||||
|
await asyncContext.DisposeAsync();
|
||||||
|
}
|
||||||
|
else if (Context is IDisposable disposableContext)
|
||||||
|
{
|
||||||
|
disposableContext.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadContext.Unload();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public abstract class PluginBase : IPlugin
|
||||||
|
{
|
||||||
|
public virtual void Initialize(IPluginContext context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginDesktopComponentContext
|
||||||
|
{
|
||||||
|
public PluginDesktopComponentContext(
|
||||||
|
PluginManifest manifest,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyDictionary<string, object?> properties,
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
double cellSize)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
|
||||||
|
Manifest = manifest;
|
||||||
|
PluginDirectory = pluginDirectory;
|
||||||
|
DataDirectory = dataDirectory;
|
||||||
|
Services = services;
|
||||||
|
Properties = properties;
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||||
|
CellSize = Math.Max(1, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
public string PluginDirectory { get; }
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
|
||||||
|
public IServiceProvider Services { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
|
public double CellSize { get; }
|
||||||
|
|
||||||
|
public T? GetService<T>()
|
||||||
|
{
|
||||||
|
return (T?)Services.GetService(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetProperty<T>(string key, out T? value)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
|
||||||
|
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||||
|
{
|
||||||
|
value = typedValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginDesktopComponentRegistration
|
||||||
|
{
|
||||||
|
public PluginDesktopComponentRegistration(
|
||||||
|
string componentId,
|
||||||
|
string displayName,
|
||||||
|
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
string category = "Plugins",
|
||||||
|
int minWidthCells = 2,
|
||||||
|
int minHeightCells = 2,
|
||||||
|
bool allowDesktopPlacement = true,
|
||||||
|
bool allowStatusBarPlacement = false,
|
||||||
|
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
string? displayNameLocalizationKey = null,
|
||||||
|
Func<double, double>? cornerRadiusResolver = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||||
|
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||||
|
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
DisplayName = displayName.Trim();
|
||||||
|
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||||
|
? null
|
||||||
|
: displayNameLocalizationKey.Trim();
|
||||||
|
ControlFactory = controlFactory;
|
||||||
|
IconKey = iconKey.Trim();
|
||||||
|
Category = category.Trim();
|
||||||
|
MinWidthCells = Math.Max(1, minWidthCells);
|
||||||
|
MinHeightCells = Math.Max(1, minHeightCells);
|
||||||
|
AllowDesktopPlacement = allowDesktopPlacement;
|
||||||
|
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||||
|
ResizeMode = resizeMode;
|
||||||
|
CornerRadiusResolver = cornerRadiusResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public string? DisplayNameLocalizationKey { get; }
|
||||||
|
|
||||||
|
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||||
|
|
||||||
|
public string IconKey { get; }
|
||||||
|
|
||||||
|
public string Category { get; }
|
||||||
|
|
||||||
|
public int MinWidthCells { get; }
|
||||||
|
|
||||||
|
public int MinHeightCells { get; }
|
||||||
|
|
||||||
|
public bool AllowDesktopPlacement { get; }
|
||||||
|
|
||||||
|
public bool AllowStatusBarPlacement { get; }
|
||||||
|
|
||||||
|
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||||
|
|
||||||
|
public Func<double, double>? CornerRadiusResolver { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum PluginDesktopComponentResizeMode
|
||||||
|
{
|
||||||
|
Proportional = 0,
|
||||||
|
Free = 1
|
||||||
|
}
|
||||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class PluginEntranceAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
private readonly AssemblyDependencyResolver _resolver;
|
||||||
|
private readonly HashSet<string> _sharedAssemblyNames;
|
||||||
|
|
||||||
|
public PluginLoadContext(string mainAssemblyPath, IEnumerable<string>? sharedAssemblyNames = null)
|
||||||
|
: base($"{Path.GetFileNameWithoutExtension(mainAssemblyPath)}_{Guid.NewGuid():N}", isCollectible: true)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(mainAssemblyPath);
|
||||||
|
|
||||||
|
MainAssemblyPath = Path.GetFullPath(mainAssemblyPath);
|
||||||
|
_resolver = new AssemblyDependencyResolver(MainAssemblyPath);
|
||||||
|
_sharedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
typeof(IPlugin).Assembly.GetName().Name!
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sharedAssemblyNames is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var assemblyName in sharedAssemblyNames)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||||
|
{
|
||||||
|
_sharedAssemblyNames.Add(assemblyName.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string MainAssemblyPath { get; }
|
||||||
|
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
var simpleName = assemblyName.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(simpleName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sharedAssemblyNames.Contains(simpleName))
|
||||||
|
{
|
||||||
|
return Default.Assemblies.FirstOrDefault(
|
||||||
|
assembly => string.Equals(
|
||||||
|
assembly.GetName().Name,
|
||||||
|
simpleName,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||||
|
return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override nint LoadUnmanagedDll(string unmanagedDllName)
|
||||||
|
{
|
||||||
|
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||||
|
return libraryPath is null ? nint.Zero : LoadUnmanagedDllFromPath(libraryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginLoadResult(
|
||||||
|
string SourcePath,
|
||||||
|
PluginManifest? Manifest,
|
||||||
|
LoadedPlugin? LoadedPlugin,
|
||||||
|
Exception? Error)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => LoadedPlugin is not null && Error is null;
|
||||||
|
|
||||||
|
public static PluginLoadResult Success(string sourcePath, PluginManifest manifest, LoadedPlugin loadedPlugin)
|
||||||
|
{
|
||||||
|
return new PluginLoadResult(sourcePath, manifest, loadedPlugin, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginLoadResult Failure(string sourcePath, PluginManifest? manifest, Exception error)
|
||||||
|
{
|
||||||
|
return new PluginLoadResult(sourcePath, manifest, null, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
924
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
924
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginLoader
|
||||||
|
{
|
||||||
|
private readonly PluginLoaderOptions _options;
|
||||||
|
|
||||||
|
public PluginLoader(PluginLoaderOptions? options = null)
|
||||||
|
{
|
||||||
|
_options = options ?? new PluginLoaderOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginLoadResult> LoadAll(
|
||||||
|
string pluginsRootDirectory,
|
||||||
|
IServiceProvider? services = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory);
|
||||||
|
|
||||||
|
if (!Directory.Exists(pluginsRootDirectory))
|
||||||
|
{
|
||||||
|
return Array.Empty<PluginLoadResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<PluginLoadResult>();
|
||||||
|
var candidates = DiscoverCandidates(pluginsRootDirectory, results);
|
||||||
|
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||||
|
{
|
||||||
|
results.Add(PluginLoadResult.Failure(
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.Manifest,
|
||||||
|
new InvalidOperationException(
|
||||||
|
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected.")));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(candidate.SourceKind switch
|
||||||
|
{
|
||||||
|
PluginSourceKind.Package => LoadFromPackage(
|
||||||
|
candidate.SourcePath,
|
||||||
|
pluginsRootDirectory,
|
||||||
|
candidate.Manifest,
|
||||||
|
services,
|
||||||
|
properties),
|
||||||
|
_ => LoadFromManifest(
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.Manifest,
|
||||||
|
services,
|
||||||
|
properties)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginLoadResult LoadFromManifest(
|
||||||
|
string manifestPath,
|
||||||
|
IServiceProvider? services = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties = null)
|
||||||
|
{
|
||||||
|
PluginManifest? manifest = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manifest = PluginManifest.Load(manifestPath);
|
||||||
|
return LoadFromManifest(manifestPath, manifest, services, properties);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginLoadResult LoadFromPackage(
|
||||||
|
string packagePath,
|
||||||
|
string pluginsRootDirectory,
|
||||||
|
IServiceProvider? services = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties = null)
|
||||||
|
{
|
||||||
|
PluginManifest? manifest = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manifest = ReadManifestFromPackage(packagePath);
|
||||||
|
return LoadFromPackage(packagePath, pluginsRootDirectory, manifest, services, properties);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginLoadResult LoadFromAssembly(
|
||||||
|
string assemblyPath,
|
||||||
|
PluginManifest manifest,
|
||||||
|
IServiceProvider? services = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
var fullAssemblyPath = Path.GetFullPath(assemblyPath);
|
||||||
|
var pluginDirectory = Path.GetDirectoryName(fullAssemblyPath)
|
||||||
|
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{fullAssemblyPath}'.");
|
||||||
|
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
|
||||||
|
return LoadCore(fullAssemblyPath, fullAssemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginLoadResult LoadCore(
|
||||||
|
string sourcePath,
|
||||||
|
string assemblyPath,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
PluginManifest manifest,
|
||||||
|
IServiceProvider? services,
|
||||||
|
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);
|
||||||
|
plugin = CreatePluginInstance(pluginType);
|
||||||
|
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
||||||
|
|
||||||
|
plugin.Initialize(context);
|
||||||
|
var settingsPages = context.GetSettingsPagesSnapshot();
|
||||||
|
var desktopComponents = context.GetDesktopComponentsSnapshot();
|
||||||
|
|
||||||
|
var loadedPlugin = new LoadedPlugin(
|
||||||
|
manifest,
|
||||||
|
sourcePath,
|
||||||
|
assemblyPath,
|
||||||
|
assembly,
|
||||||
|
plugin,
|
||||||
|
context,
|
||||||
|
settingsPages,
|
||||||
|
desktopComponents,
|
||||||
|
loadContext);
|
||||||
|
|
||||||
|
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DisposeInstance(plugin);
|
||||||
|
DisposeInstance(context);
|
||||||
|
loadContext?.Unload();
|
||||||
|
return PluginLoadResult.Failure(sourcePath, manifest, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginLoadResult LoadFromManifest(
|
||||||
|
string manifestPath,
|
||||||
|
PluginManifest manifest,
|
||||||
|
IServiceProvider? services,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullManifestPath = Path.GetFullPath(manifestPath);
|
||||||
|
var assemblyPath = manifest.ResolveEntranceAssemblyPath(fullManifestPath);
|
||||||
|
if (!File.Exists(assemblyPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Plugin '{manifest.Id}' entrance assembly '{assemblyPath}' was not found.",
|
||||||
|
assemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginDirectory = Path.GetDirectoryName(assemblyPath)
|
||||||
|
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{assemblyPath}'.");
|
||||||
|
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
|
||||||
|
return LoadCore(fullManifestPath, assemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginLoadResult LoadFromPackage(
|
||||||
|
string packagePath,
|
||||||
|
string pluginsRootDirectory,
|
||||||
|
PluginManifest manifest,
|
||||||
|
IServiceProvider? services,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||||
|
var extractionDirectory = ExtractPackage(fullPackagePath, pluginsRootDirectory);
|
||||||
|
var extractedManifestPath = Path.Combine(extractionDirectory, _options.ManifestFileName);
|
||||||
|
|
||||||
|
if (!File.Exists(extractedManifestPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.",
|
||||||
|
extractedManifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var extractedManifest = PluginManifest.Load(extractedManifestPath);
|
||||||
|
if (!string.Equals(extractedManifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{fullPackagePath}' manifest id changed after extraction. Expected '{manifest.Id}', actual '{extractedManifest.Id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var assemblyPath = extractedManifest.ResolveEntranceAssemblyPath(extractedManifestPath);
|
||||||
|
if (!File.Exists(assemblyPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Plugin '{extractedManifest.Id}' entrance assembly '{assemblyPath}' was not found after package extraction.",
|
||||||
|
assemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataDirectory = GetPackagedDataDirectory(pluginsRootDirectory, extractedManifest);
|
||||||
|
return LoadCore(fullPackagePath, assemblyPath, extractionDirectory, dataDirectory, extractedManifest, services, properties);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginContext CreateContext(
|
||||||
|
PluginManifest manifest,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
IServiceProvider? services,
|
||||||
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dataDirectory);
|
||||||
|
|
||||||
|
return new PluginContext(
|
||||||
|
manifest,
|
||||||
|
pluginDirectory,
|
||||||
|
dataDirectory,
|
||||||
|
services ?? NullServiceProvider.Instance,
|
||||||
|
CreateReadOnlyProperties(properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<PluginCandidate> DiscoverCandidates(
|
||||||
|
string pluginsRootDirectory,
|
||||||
|
List<PluginLoadResult> preparationFailures)
|
||||||
|
{
|
||||||
|
var candidates = new List<PluginCandidate>();
|
||||||
|
|
||||||
|
foreach (var packagePath in EnumerateCandidatePaths(
|
||||||
|
pluginsRootDirectory,
|
||||||
|
"*" + NormalizePackageExtension(_options.PackageFileExtension)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = ReadManifestFromPackage(packagePath);
|
||||||
|
candidates.Add(new PluginCandidate(Path.GetFullPath(packagePath), manifest, PluginSourceKind.Package));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(packagePath), null, ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var manifestPath in EnumerateCandidatePaths(pluginsRootDirectory, _options.ManifestFileName))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = PluginManifest.Load(manifestPath);
|
||||||
|
candidates.Add(new PluginCandidate(Path.GetFullPath(manifestPath), manifest, PluginSourceKind.Manifest));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(manifestPath), null, ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.OrderBy(candidate => candidate.SourceKind)
|
||||||
|
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> EnumerateCandidatePaths(string pluginsRootDirectory, string searchPattern)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(GetRuntimeRootDirectory(pluginsRootDirectory));
|
||||||
|
|
||||||
|
return Directory
|
||||||
|
.EnumerateFiles(pluginsRootDirectory, searchPattern, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
|
||||||
|
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||||
|
if (!File.Exists(fullPackagePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var archive = ZipFile.OpenRead(fullPackagePath);
|
||||||
|
var manifestEntries = archive.Entries
|
||||||
|
.Where(entry =>
|
||||||
|
!string.IsNullOrWhiteSpace(entry.Name) &&
|
||||||
|
string.Equals(entry.Name, _options.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (manifestEntries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifestEntries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{fullPackagePath}' contains multiple '{_options.ManifestFileName}' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = manifestEntries[0].Open();
|
||||||
|
return PluginManifest.Load(stream, $"{fullPackagePath}!/{manifestEntries[0].FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
||||||
|
{
|
||||||
|
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
||||||
|
RecreateDirectory(extractionDirectory);
|
||||||
|
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||||
|
return extractionDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPackageExtractionDirectory(string pluginsRootDirectory, string packagePath)
|
||||||
|
{
|
||||||
|
var packageName = SanitizeDirectoryName(Path.GetFileNameWithoutExtension(packagePath));
|
||||||
|
var packageHash = Convert.ToHexString(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(packagePath))))
|
||||||
|
.Substring(0, 12);
|
||||||
|
|
||||||
|
return Path.Combine(
|
||||||
|
GetRuntimeRootDirectory(pluginsRootDirectory),
|
||||||
|
_options.ExtractedPackagesDirectoryName,
|
||||||
|
$"{packageName}_{packageHash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPackagedDataDirectory(string pluginsRootDirectory, PluginManifest manifest)
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
GetRuntimeRootDirectory(pluginsRootDirectory),
|
||||||
|
_options.PackagedDataDirectoryName,
|
||||||
|
SanitizeDirectoryName(manifest.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRuntimeRootDirectory(string pluginsRootDirectory)
|
||||||
|
{
|
||||||
|
return Path.Combine(Path.GetFullPath(pluginsRootDirectory), _options.RuntimeDirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecreateDirectory(string directoryPath)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(directoryPath, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePackageExtension(string extension)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(extension);
|
||||||
|
return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
return fullPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? fullPath
|
||||||
|
: fullPath + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeDirectoryName(string value)
|
||||||
|
{
|
||||||
|
var invalidCharacters = Path.GetInvalidFileNameChars();
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
|
||||||
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
|
{
|
||||||
|
if (properties is null || properties.Count == 0)
|
||||||
|
{
|
||||||
|
return new ReadOnlyDictionary<string, object?>(
|
||||||
|
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
var map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var pair in properties)
|
||||||
|
{
|
||||||
|
map[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReadOnlyDictionary<string, object?>(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type ResolvePluginType(Assembly assembly)
|
||||||
|
{
|
||||||
|
var candidateTypes = GetLoadableTypes(assembly)
|
||||||
|
.Where(type =>
|
||||||
|
typeof(IPlugin).IsAssignableFrom(type) &&
|
||||||
|
!type.IsAbstract &&
|
||||||
|
!type.IsInterface &&
|
||||||
|
!type.ContainsGenericParameters)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (candidateTypes.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Assembly '{assembly.Location}' does not contain a concrete type implementing '{nameof(IPlugin)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var attributedTypes = candidateTypes
|
||||||
|
.Where(type => type.IsDefined(typeof(PluginEntranceAttribute), inherit: false))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (attributedTypes.Length == 1)
|
||||||
|
{
|
||||||
|
return attributedTypes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributedTypes.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Assembly '{assembly.Location}' contains multiple plugin entrance types. Mark only one type with '{nameof(PluginEntranceAttribute)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateTypes.Length == 1)
|
||||||
|
{
|
||||||
|
return candidateTypes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Assembly '{assembly.Location}' contains multiple '{nameof(IPlugin)}' implementations. Mark the intended entrance type with '{nameof(PluginEntranceAttribute)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPlugin CreatePluginInstance(Type pluginType)
|
||||||
|
{
|
||||||
|
if (pluginType.GetConstructor(Type.EmptyTypes) is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin type '{pluginType.FullName}' must expose a public parameterless constructor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to create plugin instance of type '{pluginType.FullName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
return assembly.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
var loaderMessages = ex.LoaderExceptions
|
||||||
|
.Where(exception => exception is not null)
|
||||||
|
.Select(exception => exception!.Message)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var detail = loaderMessages.Length == 0
|
||||||
|
? "No additional loader diagnostics were provided."
|
||||||
|
: string.Join(Environment.NewLine, loaderMessages);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to inspect plugin assembly '{assembly.Location}'.{Environment.NewLine}{detail}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyDictionary<string, object?> properties)
|
||||||
|
{
|
||||||
|
Manifest = manifest;
|
||||||
|
PluginDirectory = pluginDirectory;
|
||||||
|
DataDirectory = dataDirectory;
|
||||||
|
_hostServices = services;
|
||||||
|
Services = new PluginCompositeServiceProvider(this);
|
||||||
|
Properties = properties;
|
||||||
|
|
||||||
|
RegisterBuiltInService<IPluginContext>(this);
|
||||||
|
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
public string PluginDirectory { get; }
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
|
||||||
|
public IServiceProvider Services { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
public T? GetService<T>()
|
||||||
|
{
|
||||||
|
return (T?)Services.GetService(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetProperty<T>(string key, out T? value)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
|
||||||
|
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||||
|
{
|
||||||
|
value = typedValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
return _settingsPages.Values
|
||||||
|
.OrderBy(page => page.SortOrder)
|
||||||
|
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public static NullServiceProvider Instance { get; } = new();
|
||||||
|
|
||||||
|
private NullServiceProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? GetService(Type serviceType)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PluginSourceKind
|
||||||
|
{
|
||||||
|
Package = 0,
|
||||||
|
Manifest = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PluginCandidate(
|
||||||
|
string SourcePath,
|
||||||
|
PluginManifest Manifest,
|
||||||
|
PluginSourceKind SourceKind);
|
||||||
|
}
|
||||||
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginLoaderOptions
|
||||||
|
{
|
||||||
|
public string ManifestFileName { get; init; } = PluginSdkInfo.ManifestFileName;
|
||||||
|
|
||||||
|
public string PackageFileExtension { get; init; } = PluginSdkInfo.PackageFileExtension;
|
||||||
|
|
||||||
|
public string DataDirectoryName { get; init; } = PluginSdkInfo.DataDirectoryName;
|
||||||
|
|
||||||
|
public string RuntimeDirectoryName { get; init; } = PluginSdkInfo.RuntimeDirectoryName;
|
||||||
|
|
||||||
|
public string ExtractedPackagesDirectoryName { get; init; } = PluginSdkInfo.ExtractedPackagesDirectoryName;
|
||||||
|
|
||||||
|
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
||||||
|
|
||||||
|
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
typeof(IPlugin).Assembly.GetName().Name!
|
||||||
|
};
|
||||||
|
}
|
||||||
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginManifest(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string EntranceAssembly,
|
||||||
|
string? Description = null,
|
||||||
|
string? Author = null,
|
||||||
|
string? Version = null,
|
||||||
|
string? ApiVersion = null)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PluginManifest Load(string manifestPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||||
|
|
||||||
|
using var stream = File.OpenRead(manifestPath);
|
||||||
|
return Load(stream, manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginManifest Load(Stream stream, string sourceName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(stream);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||||
|
|
||||||
|
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest.NormalizeAndValidate(sourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||||
|
|
||||||
|
if (Path.IsPathRooted(EntranceAssembly))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(EntranceAssembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||||
|
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||||
|
{
|
||||||
|
var normalized = this with
|
||||||
|
{
|
||||||
|
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||||
|
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||||
|
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||||
|
Description = NormalizeOptionalValue(Description),
|
||||||
|
Author = NormalizeOptionalValue(Author),
|
||||||
|
Version = NormalizeOptionalValue(Version),
|
||||||
|
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedVersion.Major != currentVersion.Major)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalValue(value);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalValue(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public static class PluginSdkInfo
|
||||||
|
{
|
||||||
|
public const string ApiVersion = "1.0.0";
|
||||||
|
public const string ManifestFileName = "plugin.json";
|
||||||
|
public const string PackageFileExtension = ".laapp";
|
||||||
|
public const string DataDirectoryName = "Data";
|
||||||
|
public const string RuntimeDirectoryName = ".runtime";
|
||||||
|
public const string ExtractedPackagesDirectoryName = "packages";
|
||||||
|
public const string PackagedDataDirectoryName = "data";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginSettingsPageRegistration
|
||||||
|
{
|
||||||
|
public PluginSettingsPageRegistration(
|
||||||
|
string id,
|
||||||
|
string title,
|
||||||
|
Func<Control> contentFactory,
|
||||||
|
int sortOrder = 0)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||||
|
ArgumentNullException.ThrowIfNull(contentFactory);
|
||||||
|
|
||||||
|
Id = id.Trim();
|
||||||
|
Title = title.Trim();
|
||||||
|
ContentFactory = contentFactory;
|
||||||
|
SortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
public int SortOrder { get; }
|
||||||
|
|
||||||
|
public Func<Control> ContentFactory { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
|
<PluginPackageOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||||
|
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
||||||
|
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||||
|
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
||||||
|
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
||||||
|
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
||||||
|
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||||
|
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
84
LanMountainDesktop.SamplePlugin/SamplePlugin.cs
Normal file
84
LanMountainDesktop.SamplePlugin/SamplePlugin.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
[PluginEntrance]
|
||||||
|
public sealed class SamplePlugin : PluginBase, IDisposable
|
||||||
|
{
|
||||||
|
private SamplePluginRuntimeStateService? _stateService;
|
||||||
|
private SamplePluginClockService? _clockService;
|
||||||
|
|
||||||
|
public override void Initialize(IPluginContext context)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(context.DataDirectory);
|
||||||
|
|
||||||
|
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.");
|
||||||
|
|
||||||
|
_stateService = new SamplePluginRuntimeStateService(
|
||||||
|
context.Manifest,
|
||||||
|
context.PluginDirectory,
|
||||||
|
context.DataDirectory,
|
||||||
|
hostName,
|
||||||
|
hostVersion,
|
||||||
|
sdkApiVersion,
|
||||||
|
messageBus);
|
||||||
|
context.RegisterService(_stateService);
|
||||||
|
|
||||||
|
_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(logPath, initMessage + Environment.NewLine);
|
||||||
|
_stateService.MarkBackendReady($"Initialization log written to {logPath}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clockService.Start();
|
||||||
|
|
||||||
|
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||||
|
"status",
|
||||||
|
"Plugin Status",
|
||||||
|
() => new SamplePluginSettingsView(context)));
|
||||||
|
|
||||||
|
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||||
|
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||||
|
"Sample Plugin Status Clock",
|
||||||
|
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||||
|
iconKey: "PuzzlePiece",
|
||||||
|
category: "Plugins",
|
||||||
|
minWidthCells: 4,
|
||||||
|
minHeightCells: 4,
|
||||||
|
allowDesktopPlacement: true,
|
||||||
|
allowStatusBarPlacement: false,
|
||||||
|
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
465
LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs
Normal file
465
LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
internal enum SamplePluginHealthState
|
||||||
|
{
|
||||||
|
Healthy,
|
||||||
|
Pending,
|
||||||
|
Faulted
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SamplePluginStatusEntry(
|
||||||
|
string Key,
|
||||||
|
string Title,
|
||||||
|
SamplePluginHealthState State,
|
||||||
|
string Summary,
|
||||||
|
string Detail,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SamplePluginRuntimeStateService
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly IPluginMessageBus _messageBus;
|
||||||
|
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
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 SamplePluginStatusEntry _frontend;
|
||||||
|
private SamplePluginStatusEntry _component;
|
||||||
|
private SamplePluginStatusEntry _backend;
|
||||||
|
private SamplePluginStatusEntry _service;
|
||||||
|
private string? _lastComponentId;
|
||||||
|
private double _lastCellSize;
|
||||||
|
private DateTimeOffset? _serviceClockTime;
|
||||||
|
|
||||||
|
public SamplePluginRuntimeStateService(
|
||||||
|
PluginManifest manifest,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
string hostApplicationName,
|
||||||
|
string hostVersion,
|
||||||
|
string sdkApiVersion,
|
||||||
|
IPluginMessageBus messageBus)
|
||||||
|
{
|
||||||
|
_manifest = manifest;
|
||||||
|
_pluginDirectory = pluginDirectory;
|
||||||
|
_dataDirectory = dataDirectory;
|
||||||
|
_hostApplicationName = hostApplicationName;
|
||||||
|
_hostVersion = hostVersion;
|
||||||
|
_sdkApiVersion = sdkApiVersion;
|
||||||
|
_messageBus = messageBus;
|
||||||
|
|
||||||
|
_frontend = CreateEntry(
|
||||||
|
"frontend",
|
||||||
|
"Frontend",
|
||||||
|
SamplePluginHealthState.Pending,
|
||||||
|
"Pending",
|
||||||
|
"Waiting for a plugin UI surface to connect.");
|
||||||
|
|
||||||
|
_component = CreateEntry(
|
||||||
|
"component",
|
||||||
|
"Component",
|
||||||
|
SamplePluginHealthState.Pending,
|
||||||
|
"Pending",
|
||||||
|
"No component instance has been created yet.");
|
||||||
|
|
||||||
|
_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 void AttachClockService(SamplePluginClockService clockService)
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
"Frontend",
|
||||||
|
SamplePluginHealthState.Healthy,
|
||||||
|
"Healthy",
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishStateChanged("Frontend updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkBackendReady(string detail)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
"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(
|
||||||
|
string key,
|
||||||
|
string title,
|
||||||
|
SamplePluginHealthState state,
|
||||||
|
string summary,
|
||||||
|
string detail)
|
||||||
|
{
|
||||||
|
return new SamplePluginStatusEntry(
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
state,
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
DateTimeOffset.Now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SamplePluginClockService : IDisposable
|
||||||
|
{
|
||||||
|
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 SamplePluginClockService(
|
||||||
|
string dataDirectory,
|
||||||
|
SamplePluginRuntimeStateService stateService,
|
||||||
|
IPluginMessageBus messageBus)
|
||||||
|
{
|
||||||
|
_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()
|
||||||
|
{
|
||||||
|
PublishTick();
|
||||||
|
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerTick(object? state)
|
||||||
|
{
|
||||||
|
PublishTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishTick()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_currentTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(
|
||||||
|
_clockStateFilePath,
|
||||||
|
now.ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
_stateService.MarkClockServiceTick(now);
|
||||||
|
_messageBus.Publish(new SamplePluginClockTickMessage(now));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_stateService.MarkClockServiceFaulted($"Clock state write failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
339
LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs
Normal file
339
LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
internal sealed class SamplePluginSettingsView : UserControl
|
||||||
|
{
|
||||||
|
private readonly IPluginContext _context;
|
||||||
|
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;
|
||||||
|
_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.");
|
||||||
|
|
||||||
|
_stateService.MarkFrontendReady("Settings page is connected to plugin services and communication.");
|
||||||
|
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
|
||||||
|
Content = new Border
|
||||||
|
{
|
||||||
|
Background = new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops =
|
||||||
|
[
|
||||||
|
new GradientStop(Color.Parse("#1F0B1120"), 0),
|
||||||
|
new GradientStop(Color.Parse("#260C4A6E"), 1)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(18),
|
||||||
|
Padding = new Thickness(18),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 14,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = "Sample Plugin Capability Inspector",
|
||||||
|
FontSize = 22,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = Brushes.White
|
||||||
|
},
|
||||||
|
CreateSection("Plugin Info", _pluginInfoPanel),
|
||||||
|
CreateSection("Accessible Capabilities", _capabilityPanel),
|
||||||
|
CreateSection("Live Runtime Status", _statusPanel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
SubscribeToPluginBus();
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var subscription in _subscriptions)
|
||||||
|
{
|
||||||
|
subscription.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_subscriptions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeToPluginBus()
|
||||||
|
{
|
||||||
|
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 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();
|
||||||
|
|
||||||
|
foreach (var entry in snapshot.StatusEntries)
|
||||||
|
{
|
||||||
|
var palette = GetPalette(entry.State);
|
||||||
|
_statusPanel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(palette.Background),
|
||||||
|
BorderBrush = new SolidColorBrush(palette.Border),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
CreateStatusHeader(entry, palette),
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Detail,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}",
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
SamplePluginHealthState.Healthy => (
|
||||||
|
Color.Parse("#1F115E59"),
|
||||||
|
Color.Parse("#665EEAD4"),
|
||||||
|
Color.Parse("#5EEAD4")),
|
||||||
|
SamplePluginHealthState.Faulted => (
|
||||||
|
Color.Parse("#291B1B"),
|
||||||
|
Color.Parse("#66F87171"),
|
||||||
|
Color.Parse("#F87171")),
|
||||||
|
_ => (
|
||||||
|
Color.Parse("#2B3A2A0D"),
|
||||||
|
Color.Parse("#66FBBF24"),
|
||||||
|
Color.Parse("#FBBF24"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
284
LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs
Normal file
284
LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
internal sealed class SamplePluginStatusClockWidget : Border
|
||||||
|
{
|
||||||
|
private readonly PluginDesktopComponentContext _context;
|
||||||
|
private readonly SamplePluginRuntimeStateService _stateService;
|
||||||
|
private readonly SamplePluginClockService _clockService;
|
||||||
|
private readonly IPluginMessageBus _messageBus;
|
||||||
|
private readonly TextBlock _timeTextBlock;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
_subtitleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||||
|
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
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops =
|
||||||
|
[
|
||||||
|
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||||
|
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||||
|
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch;
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||||
|
RowSpacing = 14,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_timeTextBlock,
|
||||||
|
_subtitleTextBlock
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_statusHost
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||||
|
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
RefreshClock(_clockService.CurrentTime);
|
||||||
|
UpdateSubtitle();
|
||||||
|
RefreshStatusPanel();
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var subscription in _subscriptions)
|
||||||
|
{
|
||||||
|
subscription.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_subscriptions.Clear();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateService.UnregisterComponentInstance(_instanceId);
|
||||||
|
_instanceId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyScale();
|
||||||
|
RefreshStatusPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeToPluginBus()
|
||||||
|
{
|
||||||
|
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.068, 11, 16);
|
||||||
|
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
|
||||||
|
|
||||||
|
foreach (var entry in snapshot.StatusEntries)
|
||||||
|
{
|
||||||
|
var palette = GetPalette(entry.State);
|
||||||
|
_statusPanel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(palette.Background),
|
||||||
|
BorderBrush = new SolidColorBrush(palette.Border),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(10, 8),
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||||
|
ColumnSpacing = 8,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new Border
|
||||||
|
{
|
||||||
|
Width = Math.Clamp(basis * 0.038, 8, 11),
|
||||||
|
Height = Math.Clamp(basis * 0.038, 8, 11),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Background = new SolidColorBrush(palette.Dot),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Title,
|
||||||
|
FontSize = titleSize,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyScale()
|
||||||
|
{
|
||||||
|
var basis = GetLayoutBasis();
|
||||||
|
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);
|
||||||
|
_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()
|
||||||
|
{
|
||||||
|
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
|
||||||
|
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||||
|
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||||
|
{
|
||||||
|
return state switch
|
||||||
|
{
|
||||||
|
SamplePluginHealthState.Healthy => (
|
||||||
|
Color.Parse("#1F0F766E"),
|
||||||
|
Color.Parse("#4D5EEAD4"),
|
||||||
|
Color.Parse("#5EEAD4")),
|
||||||
|
SamplePluginHealthState.Faulted => (
|
||||||
|
Color.Parse("#29B91C1C"),
|
||||||
|
Color.Parse("#66F87171"),
|
||||||
|
Color.Parse("#F87171")),
|
||||||
|
_ => (
|
||||||
|
Color.Parse("#1F7C2D12"),
|
||||||
|
Color.Parse("#66FDBA74"),
|
||||||
|
Color.Parse("#FDBA74"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
LanMountainDesktop.SamplePlugin/plugin.json
Normal file
9
LanMountainDesktop.SamplePlugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "LanMountainDesktop.SamplePlugin",
|
||||||
|
"name": "LanMountain Sample Plugin",
|
||||||
|
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
||||||
|
"author": "LanMountainDesktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1.0.0",
|
||||||
|
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ VisualStudioVersion = 17.0.31903.59
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -15,5 +19,13 @@ Global
|
|||||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ namespace LanMountainDesktop;
|
|||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private SettingsWindow? _traySettingsWindow;
|
private SettingsWindow? _traySettingsWindow;
|
||||||
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
|
|
||||||
|
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
@@ -28,6 +31,7 @@ public partial class App : Application
|
|||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||||
|
InitializePluginRuntime();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
@@ -172,4 +176,18 @@ public partial class App : Application
|
|||||||
// Keep startup resilient if user profile folders are unavailable.
|
// Keep startup resilient if user profile folders are unavailable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializePluginRuntime()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pluginRuntimeService?.Dispose();
|
||||||
|
_pluginRuntimeService = new PluginRuntimeService();
|
||||||
|
_pluginRuntimeService.LoadInstalledPlugins();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,6 +385,13 @@ public sealed class ComponentRegistry
|
|||||||
return new ComponentRegistry(merged);
|
return new ComponentRegistry(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
|
||||||
|
{
|
||||||
|
var merged = _definitions.Values.ToList();
|
||||||
|
merged.AddRange(definitions);
|
||||||
|
return new ComponentRegistry(merged);
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
||||||
{
|
{
|
||||||
return _definitions.TryGetValue(componentId, out definition!);
|
return _definitions.TryGetValue(componentId, out definition!);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -268,9 +275,30 @@
|
|||||||
"settings.launcher.restore_button": "Show Again",
|
"settings.launcher.restore_button": "Show Again",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||||
"settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.",
|
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||||
"settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.",
|
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||||
"settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.",
|
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||||
|
"settings.plugins.installed_header": "Installed Plugins",
|
||||||
|
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
|
||||||
|
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
|
||||||
|
"settings.plugins.empty": "No plugins found.",
|
||||||
|
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
||||||
|
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||||
|
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||||
|
"settings.plugins.state.enabled": "Enabled",
|
||||||
|
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
|
||||||
|
"settings.plugins.state.disabled": "Disabled",
|
||||||
|
"settings.plugins.state.loaded": "Loaded",
|
||||||
|
"settings.plugins.state.load_failed": "Load failed",
|
||||||
|
"settings.plugins.toggle_on": "Enabled",
|
||||||
|
"settings.plugins.toggle_off": "Disabled",
|
||||||
|
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||||
|
"settings.plugins.toggle_state_enabled": "enabled",
|
||||||
|
"settings.plugins.toggle_state_disabled": "disabled",
|
||||||
|
"settings.plugins.source_package": ".laapp package",
|
||||||
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||||
"button.component_library": "Edit Desktop",
|
"button.component_library": "Edit Desktop",
|
||||||
"tooltip.component_library": "Edit Desktop",
|
"tooltip.component_library": "Edit Desktop",
|
||||||
"component_library.title": "Widgets",
|
"component_library.title": "Widgets",
|
||||||
@@ -617,3 +645,5 @@
|
|||||||
"placement.center": "Center",
|
"placement.center": "Center",
|
||||||
"placement.tile": "Tile"
|
"placement.tile": "Tile"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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": "图片文件",
|
||||||
@@ -268,9 +275,30 @@
|
|||||||
"settings.launcher.restore_button": "重新显示",
|
"settings.launcher.restore_button": "重新显示",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
"settings.plugins.runtime_header": "插件运行时",
|
"settings.plugins.runtime_header": "插件运行时",
|
||||||
"settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。",
|
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||||
"settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。",
|
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||||
"settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。",
|
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||||
|
"settings.plugins.installed_header": "已安装插件",
|
||||||
|
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
|
||||||
|
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
|
||||||
|
"settings.plugins.empty": "未找到插件。",
|
||||||
|
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||||
|
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||||
|
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||||
|
"settings.plugins.state.enabled": "已启用",
|
||||||
|
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
|
||||||
|
"settings.plugins.state.disabled": "已禁用",
|
||||||
|
"settings.plugins.state.loaded": "已加载",
|
||||||
|
"settings.plugins.state.load_failed": "加载失败",
|
||||||
|
"settings.plugins.toggle_on": "启用",
|
||||||
|
"settings.plugins.toggle_off": "禁用",
|
||||||
|
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||||
|
"settings.plugins.toggle_state_enabled": "启用",
|
||||||
|
"settings.plugins.toggle_state_disabled": "禁用",
|
||||||
|
"settings.plugins.source_package": ".laapp 包",
|
||||||
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||||
"button.component_library": "桌面编辑",
|
"button.component_library": "桌面编辑",
|
||||||
"tooltip.component_library": "桌面编辑",
|
"tooltip.component_library": "桌面编辑",
|
||||||
"component_library.title": "桌面编辑",
|
"component_library.title": "桌面编辑",
|
||||||
@@ -617,3 +645,5 @@
|
|||||||
"placement.center": "居中",
|
"placement.center": "居中",
|
||||||
"placement.tile": "平铺"
|
"placement.tile": "平铺"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public int SettingsTabIndex { get; set; } = 0;
|
public int SettingsTabIndex { get; set; } = 0;
|
||||||
|
|
||||||
|
public string? SettingsTabTag { get; set; }
|
||||||
|
|
||||||
public string LanguageCode { get; set; } = "zh-CN";
|
public string LanguageCode { get; set; } = "zh-CN";
|
||||||
|
|
||||||
public string? TimeZoneId { get; set; }
|
public string? TimeZoneId { get; set; }
|
||||||
@@ -46,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; }
|
||||||
@@ -70,6 +74,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||||
|
|
||||||
|
public List<string> DisabledPluginIds { get; set; } = [];
|
||||||
|
|
||||||
public AppSettingsSnapshot Clone()
|
public AppSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||||
@@ -80,6 +86,9 @@ public sealed class AppSettingsSnapshot
|
|||||||
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
|
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
|
||||||
? new List<string>(PinnedTaskbarActions)
|
? new List<string>(PinnedTaskbarActions)
|
||||||
: [];
|
: [];
|
||||||
|
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||||
|
? new List<string>(DisabledPluginIds)
|
||||||
|
: [];
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public static class DesktopComponentRegistryFactory
|
||||||
|
{
|
||||||
|
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
|
||||||
|
{
|
||||||
|
var registry = ComponentRegistry
|
||||||
|
.CreateDefault()
|
||||||
|
.RegisterExtensions(
|
||||||
|
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||||
|
|
||||||
|
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
|
||||||
|
return pluginDefinitions.Count == 0
|
||||||
|
? registry
|
||||||
|
: registry.RegisterComponents(pluginDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
|
||||||
|
ComponentRegistry componentRegistry,
|
||||||
|
PluginRuntimeService? pluginRuntimeService)
|
||||||
|
{
|
||||||
|
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
|
||||||
|
var registeredIds = new HashSet<string>(
|
||||||
|
registrations.Select(registration => registration.ComponentId),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (pluginRuntimeService is not null)
|
||||||
|
{
|
||||||
|
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||||
|
{
|
||||||
|
var registration = contribution.Registration;
|
||||||
|
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registeredIds.Add(registration.ComponentId))
|
||||||
|
{
|
||||||
|
Debug.WriteLine(
|
||||||
|
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations.Add(new DesktopComponentRuntimeRegistration(
|
||||||
|
registration.ComponentId,
|
||||||
|
registration.DisplayNameLocalizationKey,
|
||||||
|
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||||
|
registration.CornerRadiusResolver));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DesktopComponentDefinition> GetPluginDefinitions(
|
||||||
|
ComponentRegistry baseRegistry,
|
||||||
|
PluginRuntimeService? pluginRuntimeService)
|
||||||
|
{
|
||||||
|
var definitions = new List<DesktopComponentDefinition>();
|
||||||
|
if (pluginRuntimeService is null)
|
||||||
|
{
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownIds = new HashSet<string>(
|
||||||
|
baseRegistry.GetAll().Select(definition => definition.Id),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||||
|
{
|
||||||
|
var registration = contribution.Registration;
|
||||||
|
if (!knownIds.Add(registration.ComponentId))
|
||||||
|
{
|
||||||
|
Debug.WriteLine(
|
||||||
|
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions.Add(new DesktopComponentDefinition(
|
||||||
|
registration.ComponentId,
|
||||||
|
registration.DisplayName,
|
||||||
|
registration.IconKey,
|
||||||
|
registration.Category,
|
||||||
|
registration.MinWidthCells,
|
||||||
|
registration.MinHeightCells,
|
||||||
|
registration.AllowStatusBarPlacement,
|
||||||
|
registration.AllowDesktopPlacement,
|
||||||
|
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
|
||||||
|
? DesktopComponentResizeMode.Free
|
||||||
|
: DesktopComponentResizeMode.Proportional));
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control CreatePluginControl(
|
||||||
|
PluginDesktopComponentContribution contribution,
|
||||||
|
DesktopComponentControlFactoryContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pluginContext = new PluginDesktopComponentContext(
|
||||||
|
contribution.Plugin.Manifest,
|
||||||
|
contribution.Plugin.Context.PluginDirectory,
|
||||||
|
contribution.Plugin.Context.DataDirectory,
|
||||||
|
contribution.Plugin.Context.Services,
|
||||||
|
contribution.Plugin.Context.Properties,
|
||||||
|
contribution.Registration.ComponentId,
|
||||||
|
context.PlacementId,
|
||||||
|
context.CellSize);
|
||||||
|
|
||||||
|
return contribution.Registration.ControlFactory(pluginContext);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(
|
||||||
|
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
|
||||||
|
return CreatePluginErrorControl(contribution, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control CreatePluginErrorControl(
|
||||||
|
PluginDesktopComponentContribution contribution,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(16),
|
||||||
|
Padding = new Thickness(12),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 6,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = contribution.Registration.DisplayName,
|
||||||
|
FontSize = 14,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = exception.Message,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public enum PluginCatalogSourceKind
|
||||||
|
{
|
||||||
|
Package = 0,
|
||||||
|
Manifest = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogEntry(
|
||||||
|
PluginManifest Manifest,
|
||||||
|
string SourcePath,
|
||||||
|
bool IsPackage,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsLoaded,
|
||||||
|
string? ErrorMessage,
|
||||||
|
int SettingsPageCount,
|
||||||
|
int WidgetCount);
|
||||||
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record PluginSettingsPageContribution(
|
||||||
|
LoadedPlugin Plugin,
|
||||||
|
PluginSettingsPageRegistration Registration);
|
||||||
|
|
||||||
|
public sealed record PluginDesktopComponentContribution(
|
||||||
|
LoadedPlugin Plugin,
|
||||||
|
PluginDesktopComponentRegistration Registration);
|
||||||
328
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
328
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class PluginRuntimeService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly PluginLoader _loader;
|
||||||
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||||
|
private readonly List<PluginLoadResult> _loadResults = [];
|
||||||
|
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||||
|
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
|
||||||
|
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||||
|
|
||||||
|
public PluginRuntimeService()
|
||||||
|
{
|
||||||
|
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||||
|
_loader = new PluginLoader(CreateOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PluginsDirectory { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<LoadedPlugin> LoadedPlugins => _loadedPlugins;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginLoadResult> LoadResults => _loadResults;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogEntry> Catalog => _catalog;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginSettingsPageContribution> SettingsPages => _settingsPages;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||||
|
|
||||||
|
public void LoadInstalledPlugins()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(PluginsDirectory);
|
||||||
|
UnloadInstalledPlugins();
|
||||||
|
|
||||||
|
var disabledPluginIds = GetDisabledPluginIds();
|
||||||
|
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["HostApplicationName"] = "LanMountainDesktop",
|
||||||
|
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
|
||||||
|
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
|
||||||
|
};
|
||||||
|
|
||||||
|
var discoveryFailures = new List<PluginLoadResult>();
|
||||||
|
var candidates = DiscoverCandidates(discoveryFailures);
|
||||||
|
_loadResults.AddRange(discoveryFailures);
|
||||||
|
|
||||||
|
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||||
|
{
|
||||||
|
var duplicateFailure = PluginLoadResult.Failure(
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.Manifest,
|
||||||
|
new InvalidOperationException(
|
||||||
|
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
|
||||||
|
_loadResults.Add(duplicateFailure);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||||
|
if (!isEnabled)
|
||||||
|
{
|
||||||
|
_catalog.Add(new PluginCatalogEntry(
|
||||||
|
candidate.Manifest,
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadResult = candidate.SourceKind switch
|
||||||
|
{
|
||||||
|
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||||
|
candidate.SourcePath,
|
||||||
|
PluginsDirectory,
|
||||||
|
services: null,
|
||||||
|
hostProperties),
|
||||||
|
_ => _loader.LoadFromManifest(
|
||||||
|
candidate.SourcePath,
|
||||||
|
services: null,
|
||||||
|
hostProperties)
|
||||||
|
};
|
||||||
|
|
||||||
|
_loadResults.Add(loadResult);
|
||||||
|
|
||||||
|
if (loadResult.IsSuccess && loadResult.LoadedPlugin is not null)
|
||||||
|
{
|
||||||
|
_loadedPlugins.Add(loadResult.LoadedPlugin);
|
||||||
|
CollectContributions(loadResult.LoadedPlugin);
|
||||||
|
_catalog.Add(new PluginCatalogEntry(
|
||||||
|
loadResult.LoadedPlugin.Manifest,
|
||||||
|
loadResult.SourcePath,
|
||||||
|
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
loadResult.LoadedPlugin.SettingsPages.Count,
|
||||||
|
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||||
|
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_catalog.Add(new PluginCatalogEntry(
|
||||||
|
candidate.Manifest,
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
loadResult.Error?.Message,
|
||||||
|
0,
|
||||||
|
0));
|
||||||
|
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_catalog.Count == 0 && discoveryFailures.Count == 0)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[PluginRuntime] No .laapp packages or loose plugin manifests found under '{PluginsDirectory}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SetPluginEnabled(string pluginId, bool isEnabled)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pluginId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = _appSettingsService.Load();
|
||||||
|
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
||||||
|
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var changed = isEnabled
|
||||||
|
? disabledPluginIds.Remove(pluginId)
|
||||||
|
: disabledPluginIds.Add(pluginId);
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.DisabledPluginIds = disabledPluginIds
|
||||||
|
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
_appSettingsService.Save(snapshot);
|
||||||
|
|
||||||
|
for (var i = 0; i < _catalog.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(_catalog[i].Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_catalog[i] = _catalog[i] with { IsEnabled = isEnabled };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UnloadInstalledPlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnloadInstalledPlugins()
|
||||||
|
{
|
||||||
|
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
_loadedPlugins[i].Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadedPlugins.Clear();
|
||||||
|
_loadResults.Clear();
|
||||||
|
_catalog.Clear();
|
||||||
|
_settingsPages.Clear();
|
||||||
|
_desktopComponents.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<string> GetDisabledPluginIds()
|
||||||
|
{
|
||||||
|
var snapshot = _appSettingsService.Load();
|
||||||
|
return snapshot.DisabledPluginIds is { Count: > 0 }
|
||||||
|
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<PluginCandidate> DiscoverCandidates(List<PluginLoadResult> failures)
|
||||||
|
{
|
||||||
|
var candidates = new List<PluginCandidate>();
|
||||||
|
|
||||||
|
foreach (var packagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = ReadManifestFromPackage(packagePath);
|
||||||
|
candidates.Add(new PluginCandidate(packagePath, manifest, PluginCatalogSourceKind.Package));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add(PluginLoadResult.Failure(packagePath, null, ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var manifestPath in EnumerateCandidatePaths("plugin.json"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = PluginManifest.Load(manifestPath);
|
||||||
|
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.Manifest));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add(PluginLoadResult.Failure(manifestPath, null, ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.OrderBy(candidate => candidate.SourceKind)
|
||||||
|
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
||||||
|
|
||||||
|
return Directory
|
||||||
|
.EnumerateFiles(PluginsDirectory, searchPattern, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = entries[0].Open();
|
||||||
|
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginLoaderOptions CreateOptions()
|
||||||
|
{
|
||||||
|
var options = new PluginLoaderOptions();
|
||||||
|
AddSharedAssembly(options, typeof(App).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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSharedAssembly(PluginLoaderOptions options, Assembly assembly)
|
||||||
|
{
|
||||||
|
var assemblyName = assembly.GetName().Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||||
|
{
|
||||||
|
options.SharedAssemblyNames.Add(assemblyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||||
|
{
|
||||||
|
foreach (var settingsPage in loadedPlugin.SettingsPages)
|
||||||
|
{
|
||||||
|
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var desktopComponent in loadedPlugin.DesktopComponents)
|
||||||
|
{
|
||||||
|
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PluginCandidate(
|
||||||
|
string SourcePath,
|
||||||
|
PluginManifest Manifest,
|
||||||
|
PluginCatalogSourceKind SourceKind);
|
||||||
|
}
|
||||||
@@ -7,24 +7,65 @@ using LanMountainDesktop.Services;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public sealed record DesktopComponentRuntimeRegistration(
|
public sealed record DesktopComponentControlFactoryContext(
|
||||||
string ComponentId,
|
DesktopComponentDefinition Definition,
|
||||||
string DisplayNameLocalizationKey,
|
double CellSize,
|
||||||
Func<Control> ControlFactory,
|
TimeZoneService TimeZoneService,
|
||||||
Func<double, double>? CornerRadiusResolver = null);
|
IWeatherInfoService WeatherInfoService,
|
||||||
|
IRecommendationInfoService RecommendationInfoService,
|
||||||
|
ICalculatorDataService CalculatorDataService,
|
||||||
|
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||||
|
string? PlacementId = null);
|
||||||
|
|
||||||
|
public sealed class DesktopComponentRuntimeRegistration
|
||||||
|
{
|
||||||
|
public DesktopComponentRuntimeRegistration(
|
||||||
|
string componentId,
|
||||||
|
string? displayNameLocalizationKey,
|
||||||
|
Func<Control> controlFactory,
|
||||||
|
Func<double, double>? cornerRadiusResolver = null)
|
||||||
|
: this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopComponentRuntimeRegistration(
|
||||||
|
string componentId,
|
||||||
|
string? displayNameLocalizationKey,
|
||||||
|
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||||
|
Func<double, double>? cornerRadiusResolver = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||||
|
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||||
|
? null
|
||||||
|
: displayNameLocalizationKey.Trim();
|
||||||
|
ControlFactory = controlFactory;
|
||||||
|
CornerRadiusResolver = cornerRadiusResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public string? DisplayNameLocalizationKey { get; }
|
||||||
|
|
||||||
|
public Func<DesktopComponentControlFactoryContext, Control> ControlFactory { get; }
|
||||||
|
|
||||||
|
public Func<double, double>? CornerRadiusResolver { get; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class DesktopComponentRuntimeDescriptor
|
public sealed class DesktopComponentRuntimeDescriptor
|
||||||
{
|
{
|
||||||
private static readonly Func<double, double> DefaultCornerRadiusResolver =
|
private static readonly Func<double, double> DefaultCornerRadiusResolver =
|
||||||
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
|
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
|
||||||
|
|
||||||
private readonly Func<Control> _controlFactory;
|
private readonly Func<DesktopComponentControlFactoryContext, Control> _controlFactory;
|
||||||
private readonly Func<double, double> _cornerRadiusResolver;
|
private readonly Func<double, double> _cornerRadiusResolver;
|
||||||
|
|
||||||
internal DesktopComponentRuntimeDescriptor(
|
internal DesktopComponentRuntimeDescriptor(
|
||||||
DesktopComponentDefinition definition,
|
DesktopComponentDefinition definition,
|
||||||
string displayNameLocalizationKey,
|
string? displayNameLocalizationKey,
|
||||||
Func<Control> controlFactory,
|
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||||
Func<double, double>? cornerRadiusResolver)
|
Func<double, double>? cornerRadiusResolver)
|
||||||
{
|
{
|
||||||
Definition = definition;
|
Definition = definition;
|
||||||
@@ -35,7 +76,7 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
|
|
||||||
public DesktopComponentDefinition Definition { get; }
|
public DesktopComponentDefinition Definition { get; }
|
||||||
|
|
||||||
public string DisplayNameLocalizationKey { get; }
|
public string? DisplayNameLocalizationKey { get; }
|
||||||
|
|
||||||
public Control CreateControl(
|
public Control CreateControl(
|
||||||
double cellSize,
|
double cellSize,
|
||||||
@@ -46,7 +87,15 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
IComponentInstanceSettingsStore componentSettingsStore,
|
IComponentInstanceSettingsStore componentSettingsStore,
|
||||||
string? placementId = null)
|
string? placementId = null)
|
||||||
{
|
{
|
||||||
var control = _controlFactory();
|
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||||
|
Definition,
|
||||||
|
cellSize,
|
||||||
|
timeZoneService,
|
||||||
|
weatherInfoService,
|
||||||
|
recommendationInfoService,
|
||||||
|
calculatorDataService,
|
||||||
|
componentSettingsStore,
|
||||||
|
placementId));
|
||||||
var runtimeContext = new DesktopComponentRuntimeContext(
|
var runtimeContext = new DesktopComponentRuntimeContext(
|
||||||
Definition.Id,
|
Definition.Id,
|
||||||
placementId,
|
placementId,
|
||||||
@@ -133,12 +182,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
public static IReadOnlyList<DesktopComponentRuntimeRegistration> GetDefaultRegistrations()
|
||||||
{
|
|
||||||
return new DesktopComponentRuntimeRegistry(
|
|
||||||
componentRegistry,
|
|
||||||
new[]
|
|
||||||
{
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"component.date",
|
"component.date",
|
||||||
@@ -319,7 +366,12 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.holiday_calendar",
|
"component.holiday_calendar",
|
||||||
() => new HolidayCalendarWidget(),
|
() => new HolidayCalendarWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
|
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
|
||||||
});
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
||||||
|
{
|
||||||
|
return new DesktopComponentRuntimeRegistry(componentRegistry, GetDefaultRegistrations());
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
|
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
|
||||||
|
|||||||
@@ -3216,7 +3216,9 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private string GetLocalizedComponentDisplayName(DesktopComponentRuntimeDescriptor descriptor)
|
private string GetLocalizedComponentDisplayName(DesktopComponentRuntimeDescriptor descriptor)
|
||||||
{
|
{
|
||||||
return L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName);
|
return string.IsNullOrWhiteSpace(descriptor.DisplayNameLocalizationKey)
|
||||||
|
? descriptor.Definition.DisplayName
|
||||||
|
: L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnComponentLibraryComponentPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnComponentLibraryComponentPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -280,13 +282,22 @@ public partial class MainWindow
|
|||||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||||
PluginSystemSettingsExpander.Description = L(
|
PluginSystemSettingsExpander.Description = L(
|
||||||
"settings.plugins.runtime_desc",
|
"settings.plugins.runtime_desc",
|
||||||
"Manage plugin loading and backend isolation.");
|
"Review plugin runtime state and load results.");
|
||||||
PluginSystemDescriptionTextBlock.Text = L(
|
PluginSystemDescriptionTextBlock.Text = L(
|
||||||
"settings.plugins.runtime_hint",
|
"settings.plugins.runtime_hint",
|
||||||
"This page will host installed plugin management, permission review, and sandboxed backend runtime controls.");
|
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||||
PluginSystemStatusTextBlock.Text = L(
|
PluginSystemStatusTextBlock.Text = L(
|
||||||
"settings.plugins.runtime_status",
|
"settings.plugins.runtime_status",
|
||||||
"Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.");
|
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||||
|
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
|
InstalledPluginsSettingsExpander.Description = L(
|
||||||
|
"settings.plugins.installed_desc",
|
||||||
|
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||||
|
PluginRestartHintTextBlock.Text = L(
|
||||||
|
"settings.plugins.restart_hint",
|
||||||
|
"Plugin enable state changes take effect after restarting the app.");
|
||||||
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
|
||||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
@@ -306,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)
|
||||||
{
|
{
|
||||||
@@ -332,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);
|
||||||
@@ -428,3 +461,4 @@ public partial class MainWindow
|
|||||||
: _weatherLocationKey);
|
: _weatherLocationKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
LanMountainDesktop/Views/MainWindow.PluginSettings.cs
Normal file
193
LanMountainDesktop/Views/MainWindow.PluginSettings.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using FluentIcons.Avalonia.Fluent;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Control> _pluginSettingsPageHosts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private void InitializePluginSettingsNavigation()
|
||||||
|
{
|
||||||
|
if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
var contributions = runtime?.SettingsPages
|
||||||
|
.OrderBy(contribution => contribution.Registration.SortOrder)
|
||||||
|
.ThenBy(contribution => contribution.Plugin.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(contribution => contribution.Registration.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (contributions is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageCountsByPluginId = contributions
|
||||||
|
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
|
||||||
|
foreach (var contribution in contributions)
|
||||||
|
{
|
||||||
|
var tag = BuildPluginSettingsTag(contribution);
|
||||||
|
var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId);
|
||||||
|
var navItem = new NavigationViewItem
|
||||||
|
{
|
||||||
|
Content = navigationTitle,
|
||||||
|
Tag = tag,
|
||||||
|
IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}");
|
||||||
|
|
||||||
|
SettingsNavView.MenuItems.Insert(insertIndex++, navItem);
|
||||||
|
|
||||||
|
var pageHost = CreatePluginSettingsPageHost(contribution);
|
||||||
|
pageHost.IsVisible = false;
|
||||||
|
SettingsContentPagesHost.Children.Add(pageHost);
|
||||||
|
_pluginSettingsPageHosts[tag] = pageHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution)
|
||||||
|
{
|
||||||
|
return $"PluginPage:{contribution.Plugin.Manifest.Id}:{contribution.Registration.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPluginSettingsNavigationTitle(
|
||||||
|
PluginSettingsPageContribution contribution,
|
||||||
|
IReadOnlyDictionary<string, int> pageCountsByPluginId)
|
||||||
|
{
|
||||||
|
return pageCountsByPluginId.TryGetValue(contribution.Plugin.Manifest.Id, out var pageCount) && pageCount > 1
|
||||||
|
? $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}"
|
||||||
|
: contribution.Plugin.Manifest.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreatePluginSettingsPageHost(PluginSettingsPageContribution contribution)
|
||||||
|
{
|
||||||
|
Control content;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
content = contribution.Registration.ContentFactory();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
content = CreatePluginPageErrorContent(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 16,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = contribution.Registration.Title,
|
||||||
|
FontSize = 24,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush")
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = contribution.Plugin.Manifest.Name,
|
||||||
|
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush")
|
||||||
|
},
|
||||||
|
content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreatePluginPageErrorContent(Exception exception)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(16),
|
||||||
|
Padding = new Thickness(16),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = exception.Message,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePluginSettingsPageVisibility(string? selectedTag)
|
||||||
|
{
|
||||||
|
foreach (var pair in _pluginSettingsPageHosts)
|
||||||
|
{
|
||||||
|
pair.Value.IsVisible = string.Equals(pair.Key, selectedTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetSelectedSettingsTabTag()
|
||||||
|
{
|
||||||
|
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ResolveSelectedSettingsTabIndex()
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < SettingsNavView.MenuItems.Count; i++)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag))
|
||||||
|
{
|
||||||
|
var taggedItem = SettingsNavView.MenuItems
|
||||||
|
.OfType<NavigationViewItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (taggedItem is not null)
|
||||||
|
{
|
||||||
|
SettingsNavView.SelectedItem = taggedItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1));
|
||||||
|
if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem)
|
||||||
|
{
|
||||||
|
SettingsNavView.SelectedItem = navItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -100,24 +100,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private int GetSettingsTabIndex()
|
private int GetSettingsTabIndex()
|
||||||
{
|
{
|
||||||
if (SettingsNavView?.SelectedItem is FluentAvalonia.UI.Controls.NavigationViewItem item)
|
return ResolveSelectedSettingsTabIndex();
|
||||||
{
|
|
||||||
return item.Tag?.ToString() switch
|
|
||||||
{
|
|
||||||
"Wallpaper" => 0,
|
|
||||||
"Grid" => 1,
|
|
||||||
"Color" => 2,
|
|
||||||
"StatusBar" => 3,
|
|
||||||
"Weather" => 4,
|
|
||||||
"Region" => 5,
|
|
||||||
"Update" => 6,
|
|
||||||
"About" => 7,
|
|
||||||
"Launcher" => 8,
|
|
||||||
"Plugins" => 9,
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSettingsTabContent()
|
private void UpdateSettingsTabContent()
|
||||||
@@ -150,6 +133,7 @@ public partial class MainWindow
|
|||||||
AboutSettingsPanel.IsVisible = tag == "About";
|
AboutSettingsPanel.IsVisible = tag == "About";
|
||||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||||
|
UpdatePluginSettingsPageVisibility(tag);
|
||||||
|
|
||||||
if (tag == "Launcher")
|
if (tag == "Launcher")
|
||||||
{
|
{
|
||||||
@@ -984,10 +968,7 @@ public partial class MainWindow
|
|||||||
GridSizeNumberBox.Value = _targetShortSideCells;
|
GridSizeNumberBox.Value = _targetShortSideCells;
|
||||||
GridSizeSlider.Value = _targetShortSideCells;
|
GridSizeSlider.Value = _targetShortSideCells;
|
||||||
|
|
||||||
if (SettingsNavView.MenuItems.ElementAtOrDefault(Math.Clamp(snapshot.SettingsTabIndex, 0, 9)) is FluentAvalonia.UI.Controls.NavigationViewItem navItem)
|
RestoreSettingsTabSelection(snapshot);
|
||||||
{
|
|
||||||
SettingsNavView.SelectedItem = navItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateSettingsTabContent();
|
UpdateSettingsTabContent();
|
||||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||||
@@ -997,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);
|
||||||
@@ -1036,42 +1018,42 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
||||||
{
|
{
|
||||||
return new AppSettingsSnapshot
|
var snapshot = _appSettingsService.Load();
|
||||||
{
|
snapshot.GridShortSideCells = _targetShortSideCells;
|
||||||
GridShortSideCells = _targetShortSideCells,
|
snapshot.GridSpacingPreset = _gridSpacingPreset;
|
||||||
GridSpacingPreset = _gridSpacingPreset,
|
snapshot.DesktopEdgeInsetPercent = _desktopEdgeInsetPercent;
|
||||||
DesktopEdgeInsetPercent = _desktopEdgeInsetPercent,
|
snapshot.IsNightMode = _isNightMode;
|
||||||
IsNightMode = _isNightMode,
|
snapshot.ThemeColor = _selectedThemeColor.ToString();
|
||||||
ThemeColor = _selectedThemeColor.ToString(),
|
snapshot.WallpaperPath = _wallpaperPath;
|
||||||
WallpaperPath = _wallpaperPath,
|
snapshot.WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement());
|
||||||
WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()),
|
snapshot.SettingsTabIndex = Math.Max(0, GetSettingsTabIndex());
|
||||||
SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()),
|
snapshot.SettingsTabTag = GetSelectedSettingsTabTag();
|
||||||
LanguageCode = _languageCode,
|
snapshot.LanguageCode = _languageCode;
|
||||||
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
snapshot.TimeZoneId = _timeZoneService.CurrentTimeZone.Id;
|
||||||
WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode),
|
snapshot.WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode);
|
||||||
WeatherLocationKey = _weatherLocationKey,
|
snapshot.WeatherLocationKey = _weatherLocationKey;
|
||||||
WeatherLocationName = _weatherLocationName,
|
snapshot.WeatherLocationName = _weatherLocationName;
|
||||||
WeatherLatitude = _weatherLatitude,
|
snapshot.WeatherLatitude = _weatherLatitude;
|
||||||
WeatherLongitude = _weatherLongitude,
|
snapshot.WeatherLongitude = _weatherLongitude;
|
||||||
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
|
snapshot.WeatherAutoRefreshLocation = _weatherAutoRefreshLocation;
|
||||||
WeatherLocationQuery = BuildLegacyWeatherLocationQuery(),
|
snapshot.WeatherLocationQuery = BuildLegacyWeatherLocationQuery();
|
||||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
snapshot.WeatherExcludedAlerts = _weatherExcludedAlertsRaw;
|
||||||
WeatherIconPackId = _weatherIconPackId,
|
snapshot.WeatherIconPackId = _weatherIconPackId;
|
||||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
|
||||||
AutoStartWithWindows = _autoStartWithWindows,
|
snapshot.AutoStartWithWindows = _autoStartWithWindows;
|
||||||
AutoCheckUpdates = _autoCheckUpdates,
|
snapshot.AppRenderMode = _selectedAppRenderMode;
|
||||||
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
|
snapshot.AutoCheckUpdates = _autoCheckUpdates;
|
||||||
UpdateChannel = IncludePrereleaseUpdates ? "Preview" : "Stable",
|
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
|
||||||
TopStatusComponentIds = _topStatusComponentIds.ToList(),
|
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;
|
||||||
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
|
snapshot.TopStatusComponentIds = _topStatusComponentIds.ToList();
|
||||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
snapshot.PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList();
|
||||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
snapshot.EnableDynamicTaskbarActions = _enableDynamicTaskbarActions;
|
||||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
snapshot.TaskbarLayoutMode = _taskbarLayoutMode;
|
||||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
snapshot.ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond";
|
||||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
snapshot.StatusBarSpacingMode = _statusBarSpacingMode;
|
||||||
};
|
snapshot.StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent;
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
|
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
|
||||||
{
|
{
|
||||||
return new DesktopLayoutSettingsSnapshot
|
return new DesktopLayoutSettingsSnapshot
|
||||||
@@ -1081,7 +1063,6 @@ public partial class MainWindow
|
|||||||
DesktopComponentPlacements = _desktopComponentPlacements.ToList()
|
DesktopComponentPlacements = _desktopComponentPlacements.ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot()
|
private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot()
|
||||||
{
|
{
|
||||||
return new LauncherSettingsSnapshot
|
return new LauncherSettingsSnapshot
|
||||||
@@ -1241,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)
|
||||||
@@ -1508,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)
|
||||||
@@ -2661,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")!;
|
||||||
@@ -2678,4 +2717,10 @@ public partial class MainWindow
|
|||||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ using Avalonia.Styling;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using FluentAvalonia.Styling;
|
using FluentAvalonia.Styling;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
@@ -98,11 +97,7 @@ public partial class MainWindow : Window
|
|||||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
private readonly ComponentRegistry _componentRegistry;
|
||||||
.CreateDefault()
|
|
||||||
.RegisterExtensions(
|
|
||||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
|
||||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
|
||||||
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
||||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||||
private readonly HashSet<string> _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -172,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;
|
||||||
@@ -182,8 +179,14 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
|
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
|
InitializePluginSettingsNavigation();
|
||||||
|
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||||
|
_componentRegistry,
|
||||||
|
pluginRuntimeService);
|
||||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||||
AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved;
|
AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved;
|
||||||
LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved;
|
LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved;
|
||||||
@@ -247,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)
|
||||||
@@ -300,10 +304,7 @@ public partial class MainWindow : Window
|
|||||||
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
||||||
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
|
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
|
||||||
|
|
||||||
if (SettingsNavView.MenuItems.ElementAtOrDefault(Math.Clamp(snapshot.SettingsTabIndex, 0, 9)) is FluentAvalonia.UI.Controls.NavigationViewItem navItem)
|
RestoreSettingsTabSelection(snapshot);
|
||||||
{
|
|
||||||
SettingsNavView.SelectedItem = navItem;
|
|
||||||
}
|
|
||||||
UpdateSettingsTabContent();
|
UpdateSettingsTabContent();
|
||||||
|
|
||||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -35,6 +35,31 @@
|
|||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
Text="Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel." />
|
Text="Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel." />
|
||||||
</Border>
|
</Border>
|
||||||
|
<StackPanel x:Name="PluginRuntimeSummaryPanel" Spacing="6" />
|
||||||
|
</StackPanel>
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settings-expander-shell">
|
||||||
|
<ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander"
|
||||||
|
Header="Installed Plugins"
|
||||||
|
Description="Enable plugins here. Detailed settings appear as separate settings pages."
|
||||||
|
IsExpanded="True">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock x:Name="PluginRestartHintTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Plugin enable state changes take effect after restarting the app." />
|
||||||
|
<TextBlock x:Name="PluginCatalogEmptyTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="No plugins found."
|
||||||
|
IsVisible="False" />
|
||||||
|
<StackPanel x:Name="PluginCatalogItemsHost" Spacing="12" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|||||||
@@ -1,12 +1,222 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.SettingsPages;
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
public partial class PluginSettingsPage : UserControl
|
public partial class PluginSettingsPage : UserControl
|
||||||
{
|
{
|
||||||
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
|
||||||
public PluginSettingsPage()
|
public PluginSettingsPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
AttachedToVisualTree += (_, _) => RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshFromRuntime()
|
||||||
|
{
|
||||||
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
if (runtime is null)
|
||||||
|
{
|
||||||
|
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||||
|
PluginRuntimeSummaryPanel.Children.Clear();
|
||||||
|
PluginCatalogItemsHost.Children.Clear();
|
||||||
|
PluginRestartHintTextBlock.IsVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildRuntimeSummary(runtime);
|
||||||
|
BuildPluginCatalog(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
||||||
|
{
|
||||||
|
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
||||||
|
var enabledCount = runtime.Catalog.Count(entry => entry.IsEnabled);
|
||||||
|
PluginSystemStatusTextBlock.Text = F(
|
||||||
|
"settings.plugins.summary_format",
|
||||||
|
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||||
|
runtime.Catalog.Count,
|
||||||
|
enabledCount,
|
||||||
|
runtime.LoadedPlugins.Count,
|
||||||
|
runtime.SettingsPages.Count,
|
||||||
|
runtime.DesktopComponents.Count,
|
||||||
|
failures.Length);
|
||||||
|
|
||||||
|
PluginRuntimeSummaryPanel.Children.Clear();
|
||||||
|
foreach (var plugin in runtime.Catalog.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var status = plugin.IsEnabled
|
||||||
|
? plugin.IsLoaded
|
||||||
|
? L("settings.plugins.state.enabled", "Enabled")
|
||||||
|
: L("settings.plugins.state.enabled_failed", "Enabled / failed to load")
|
||||||
|
: L("settings.plugins.state.disabled", "Disabled");
|
||||||
|
PluginRuntimeSummaryPanel.Children.Add(CreateSummaryLine(
|
||||||
|
F(
|
||||||
|
"settings.plugins.summary_item_format",
|
||||||
|
"{0} v{1} | {2}",
|
||||||
|
plugin.Manifest.Name,
|
||||||
|
plugin.Manifest.Version ?? "dev",
|
||||||
|
status)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildPluginCatalog(PluginRuntimeService runtime)
|
||||||
|
{
|
||||||
|
PluginCatalogItemsHost.Children.Clear();
|
||||||
|
|
||||||
|
var plugins = runtime.Catalog
|
||||||
|
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
PluginCatalogEmptyTextBlock.IsVisible = plugins.Count == 0;
|
||||||
|
PluginRestartHintTextBlock.IsVisible = plugins.Count > 0;
|
||||||
|
|
||||||
|
foreach (var plugin in plugins)
|
||||||
|
{
|
||||||
|
PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
|
||||||
|
{
|
||||||
|
var title = new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Manifest.Name,
|
||||||
|
FontSize = 16,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
var subtitle = new TextBlock
|
||||||
|
{
|
||||||
|
Text = BuildPluginSubtitle(entry),
|
||||||
|
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
var enabledToggle = new ToggleSwitch
|
||||||
|
{
|
||||||
|
IsChecked = entry.IsEnabled,
|
||||||
|
OnContent = L("settings.plugins.toggle_on", "Enabled"),
|
||||||
|
OffContent = L("settings.plugins.toggle_off", "Disabled"),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true);
|
||||||
|
enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false);
|
||||||
|
|
||||||
|
var header = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||||
|
ColumnSpacing = 12,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
Children = { title, subtitle }
|
||||||
|
},
|
||||||
|
enabledToggle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Grid.SetColumn(enabledToggle, 1);
|
||||||
|
|
||||||
|
var details = new TextBlock
|
||||||
|
{
|
||||||
|
Text = BuildPluginDetails(entry),
|
||||||
|
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
||||||
|
CornerRadius = new CornerRadius(16),
|
||||||
|
Padding = new Thickness(14),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 10,
|
||||||
|
Children = { header, details }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
|
||||||
|
{
|
||||||
|
runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled);
|
||||||
|
BuildRuntimeSummary(runtime);
|
||||||
|
BuildPluginCatalog(runtime);
|
||||||
|
PluginSystemStatusTextBlock.Text = F(
|
||||||
|
"settings.plugins.toggle_result_format",
|
||||||
|
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||||
|
entry.Manifest.Name,
|
||||||
|
isEnabled
|
||||||
|
? L("settings.plugins.toggle_state_enabled", "enabled")
|
||||||
|
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||||
|
{
|
||||||
|
var source = entry.IsPackage
|
||||||
|
? L("settings.plugins.source_package", ".laapp package")
|
||||||
|
: L("settings.plugins.source_manifest", "Loose manifest");
|
||||||
|
var state = entry.IsEnabled
|
||||||
|
? entry.IsLoaded
|
||||||
|
? L("settings.plugins.state.loaded", "Loaded")
|
||||||
|
: L("settings.plugins.state.load_failed", "Load failed")
|
||||||
|
: L("settings.plugins.state.disabled", "Disabled");
|
||||||
|
return F(
|
||||||
|
"settings.plugins.subtitle_format",
|
||||||
|
"{0} | {1} | {2}",
|
||||||
|
state,
|
||||||
|
source,
|
||||||
|
entry.Manifest.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPluginDetails(PluginCatalogEntry entry)
|
||||||
|
{
|
||||||
|
var detail = F(
|
||||||
|
"settings.plugins.detail_format",
|
||||||
|
"Settings pages: {0} | Widgets: {1}",
|
||||||
|
entry.SettingsPageCount,
|
||||||
|
entry.WidgetCount);
|
||||||
|
return string.IsNullOrWhiteSpace(entry.ErrorMessage)
|
||||||
|
? detail
|
||||||
|
: detail + Environment.NewLine + entry.ErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock CreateSummaryLine(string text)
|
||||||
|
{
|
||||||
|
return new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = PluginSystemDescriptionTextBlock.Foreground
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var snapshot = _appSettingsService.Load();
|
||||||
|
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string F(string key, string fallback, params object[] args)
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using LibVLCSharp.Avalonia;
|
using LibVLCSharp.Avalonia;
|
||||||
@@ -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")!;
|
||||||
@@ -206,4 +208,8 @@ public partial class SettingsWindow
|
|||||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -54,25 +54,7 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
private int GetSettingsTabIndex()
|
private int GetSettingsTabIndex()
|
||||||
{
|
{
|
||||||
if (SettingsNavView?.SelectedItem is FluentAvalonia.UI.Controls.NavigationViewItem item)
|
return ResolveSelectedSettingsTabIndex();
|
||||||
{
|
|
||||||
return item.Tag?.ToString() switch
|
|
||||||
{
|
|
||||||
"Wallpaper" => 0,
|
|
||||||
"Grid" => 1,
|
|
||||||
"Color" => 2,
|
|
||||||
"StatusBar" => 3,
|
|
||||||
"Weather" => 4,
|
|
||||||
"Region" => 5,
|
|
||||||
"Update" => 6,
|
|
||||||
"About" => 7,
|
|
||||||
"Launcher" => 8,
|
|
||||||
"Plugins" => 9,
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSettingsTabContent()
|
private void UpdateSettingsTabContent()
|
||||||
@@ -95,6 +77,7 @@ public partial class SettingsWindow
|
|||||||
AboutSettingsPanel.IsVisible = tag == "About";
|
AboutSettingsPanel.IsVisible = tag == "About";
|
||||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||||
|
UpdatePluginSettingsPageVisibility(tag);
|
||||||
|
|
||||||
if (tag == "Launcher")
|
if (tag == "Launcher")
|
||||||
{
|
{
|
||||||
@@ -122,40 +105,41 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
||||||
{
|
{
|
||||||
return new AppSettingsSnapshot
|
var snapshot = _appSettingsService.Load();
|
||||||
{
|
snapshot.GridShortSideCells = _targetShortSideCells;
|
||||||
GridShortSideCells = _targetShortSideCells,
|
snapshot.GridSpacingPreset = _gridSpacingPreset;
|
||||||
GridSpacingPreset = _gridSpacingPreset,
|
snapshot.DesktopEdgeInsetPercent = _desktopEdgeInsetPercent;
|
||||||
DesktopEdgeInsetPercent = _desktopEdgeInsetPercent,
|
snapshot.IsNightMode = _isNightMode;
|
||||||
IsNightMode = _isNightMode,
|
snapshot.ThemeColor = _selectedThemeColor.ToString();
|
||||||
ThemeColor = _selectedThemeColor.ToString(),
|
snapshot.WallpaperPath = _wallpaperPath;
|
||||||
WallpaperPath = _wallpaperPath,
|
snapshot.WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement());
|
||||||
WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()),
|
snapshot.SettingsTabIndex = Math.Max(0, GetSettingsTabIndex());
|
||||||
SettingsTabIndex = Math.Max(0, GetSettingsTabIndex()),
|
snapshot.SettingsTabTag = GetSelectedSettingsTabTag();
|
||||||
LanguageCode = _languageCode,
|
snapshot.LanguageCode = _languageCode;
|
||||||
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
snapshot.TimeZoneId = _timeZoneService.CurrentTimeZone.Id;
|
||||||
WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode),
|
snapshot.WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode);
|
||||||
WeatherLocationKey = _weatherLocationKey,
|
snapshot.WeatherLocationKey = _weatherLocationKey;
|
||||||
WeatherLocationName = _weatherLocationName,
|
snapshot.WeatherLocationName = _weatherLocationName;
|
||||||
WeatherLatitude = _weatherLatitude,
|
snapshot.WeatherLatitude = _weatherLatitude;
|
||||||
WeatherLongitude = _weatherLongitude,
|
snapshot.WeatherLongitude = _weatherLongitude;
|
||||||
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
|
snapshot.WeatherAutoRefreshLocation = _weatherAutoRefreshLocation;
|
||||||
WeatherLocationQuery = BuildLegacyWeatherLocationQuery(),
|
snapshot.WeatherLocationQuery = BuildLegacyWeatherLocationQuery();
|
||||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
snapshot.WeatherExcludedAlerts = _weatherExcludedAlertsRaw;
|
||||||
WeatherIconPackId = _weatherIconPackId,
|
snapshot.WeatherIconPackId = _weatherIconPackId;
|
||||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
|
||||||
AutoStartWithWindows = _autoStartWithWindows,
|
snapshot.AutoStartWithWindows = _autoStartWithWindows;
|
||||||
AutoCheckUpdates = _autoCheckUpdates,
|
snapshot.AppRenderMode = _selectedAppRenderMode;
|
||||||
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
|
snapshot.AutoCheckUpdates = _autoCheckUpdates;
|
||||||
UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable,
|
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
|
||||||
TopStatusComponentIds = _topStatusComponentIds.ToList(),
|
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;
|
||||||
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
|
snapshot.TopStatusComponentIds = _topStatusComponentIds.ToList();
|
||||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
snapshot.PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList();
|
||||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
snapshot.EnableDynamicTaskbarActions = _enableDynamicTaskbarActions;
|
||||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
snapshot.TaskbarLayoutMode = _taskbarLayoutMode;
|
||||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
snapshot.ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond";
|
||||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
snapshot.StatusBarSpacingMode = _statusBarSpacingMode;
|
||||||
};
|
snapshot.StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent;
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot()
|
private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot()
|
||||||
@@ -514,3 +498,4 @@ public partial class SettingsWindow
|
|||||||
return Brushes.Transparent;
|
return Brushes.Transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -111,9 +112,14 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Manage plugin loading and backend isolation.");
|
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.");
|
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.");
|
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||||
|
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
|
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||||
|
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||||
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||||
@@ -121,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)
|
||||||
@@ -137,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);
|
||||||
@@ -183,3 +211,4 @@ public partial class SettingsWindow
|
|||||||
PersistSettings();
|
PersistSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs
Normal file
193
LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using FluentIcons.Avalonia.Fluent;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Control> _pluginSettingsPageHosts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private void InitializePluginSettingsNavigation()
|
||||||
|
{
|
||||||
|
if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
var contributions = runtime?.SettingsPages
|
||||||
|
.OrderBy(contribution => contribution.Registration.SortOrder)
|
||||||
|
.ThenBy(contribution => contribution.Plugin.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(contribution => contribution.Registration.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (contributions is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageCountsByPluginId = contributions
|
||||||
|
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
|
||||||
|
foreach (var contribution in contributions)
|
||||||
|
{
|
||||||
|
var tag = BuildPluginSettingsTag(contribution);
|
||||||
|
var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId);
|
||||||
|
var navItem = new NavigationViewItem
|
||||||
|
{
|
||||||
|
Content = navigationTitle,
|
||||||
|
Tag = tag,
|
||||||
|
IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}");
|
||||||
|
|
||||||
|
SettingsNavView.MenuItems.Insert(insertIndex++, navItem);
|
||||||
|
|
||||||
|
var pageHost = CreatePluginSettingsPageHost(contribution);
|
||||||
|
pageHost.IsVisible = false;
|
||||||
|
SettingsContentPagesHost.Children.Add(pageHost);
|
||||||
|
_pluginSettingsPageHosts[tag] = pageHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution)
|
||||||
|
{
|
||||||
|
return $"PluginPage:{contribution.Plugin.Manifest.Id}:{contribution.Registration.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPluginSettingsNavigationTitle(
|
||||||
|
PluginSettingsPageContribution contribution,
|
||||||
|
IReadOnlyDictionary<string, int> pageCountsByPluginId)
|
||||||
|
{
|
||||||
|
return pageCountsByPluginId.TryGetValue(contribution.Plugin.Manifest.Id, out var pageCount) && pageCount > 1
|
||||||
|
? $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}"
|
||||||
|
: contribution.Plugin.Manifest.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreatePluginSettingsPageHost(PluginSettingsPageContribution contribution)
|
||||||
|
{
|
||||||
|
Control content;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
content = contribution.Registration.ContentFactory();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
content = CreatePluginPageErrorContent(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 16,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = contribution.Registration.Title,
|
||||||
|
FontSize = 24,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush")
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = contribution.Plugin.Manifest.Name,
|
||||||
|
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush")
|
||||||
|
},
|
||||||
|
content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control CreatePluginPageErrorContent(Exception exception)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(16),
|
||||||
|
Padding = new Thickness(16),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = exception.Message,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePluginSettingsPageVisibility(string? selectedTag)
|
||||||
|
{
|
||||||
|
foreach (var pair in _pluginSettingsPageHosts)
|
||||||
|
{
|
||||||
|
pair.Value.IsVisible = string.Equals(pair.Key, selectedTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetSelectedSettingsTabTag()
|
||||||
|
{
|
||||||
|
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ResolveSelectedSettingsTabIndex()
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < SettingsNavView.MenuItems.Count; i++)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag))
|
||||||
|
{
|
||||||
|
var taggedItem = SettingsNavView.MenuItems
|
||||||
|
.OfType<NavigationViewItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (taggedItem is not null)
|
||||||
|
{
|
||||||
|
SettingsNavView.SelectedItem = taggedItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1));
|
||||||
|
if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem)
|
||||||
|
{
|
||||||
|
SettingsNavView.SelectedItem = navItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -141,7 +141,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" />
|
||||||
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
|
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public partial class SettingsWindow : Window
|
|||||||
private readonly WindowsStartupService _windowsStartupService = new();
|
private readonly WindowsStartupService _windowsStartupService = new();
|
||||||
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry.CreateDefault();
|
private readonly ComponentRegistry _componentRegistry;
|
||||||
private readonly WindowsStartMenuService _windowsStartMenuService = new();
|
private readonly WindowsStartMenuService _windowsStartMenuService = new();
|
||||||
private readonly LinuxDesktopEntryService _linuxDesktopEntryService = new();
|
private readonly LinuxDesktopEntryService _linuxDesktopEntryService = new();
|
||||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||||
@@ -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;
|
||||||
@@ -158,7 +160,9 @@ public partial class SettingsWindow : Window
|
|||||||
|
|
||||||
public SettingsWindow()
|
public SettingsWindow()
|
||||||
{
|
{
|
||||||
|
_componentRegistry = DesktopComponentRegistryFactory.Create((Application.Current as App)?.PluginRuntimeService);
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
InitializePluginSettingsNavigation();
|
||||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||||
RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default;
|
RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default;
|
||||||
HookEvents();
|
HookEvents();
|
||||||
@@ -220,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,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();
|
||||||
@@ -278,6 +284,7 @@ public partial class SettingsWindow : Window
|
|||||||
WindowTitleTextBlock.Text = L("settings.title", "Settings");
|
WindowTitleTextBlock.Text = L("settings.title", "Settings");
|
||||||
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
|
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
|
||||||
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
|
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
|
||||||
|
RestoreSettingsTabSelection(snapshot);
|
||||||
UpdateSettingsTabContent();
|
UpdateSettingsTabContent();
|
||||||
UpdateWallpaperDisplay();
|
UpdateWallpaperDisplay();
|
||||||
UpdateWallpaperPreviewLayout();
|
UpdateWallpaperPreviewLayout();
|
||||||
|
|||||||
Reference in New Issue
Block a user