mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
feat.开发者调试工具
This commit is contained in:
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed class DevPluginOptions
|
||||
{
|
||||
private static readonly string[] DevPluginPathArgs = ["--dev-plugin", "-dp"];
|
||||
private static readonly string[] DevModeArgs = ["--dev-mode", "-dev"];
|
||||
private static readonly string[] HotReloadArgs = ["--hot-reload", "-hr"];
|
||||
private static readonly string EnvDevPluginPath = "LMD_DEV_PLUGIN";
|
||||
private static readonly string EnvDevMode = "LMD_DEV_MODE";
|
||||
|
||||
public static DevPluginOptions Current { get; } = new();
|
||||
|
||||
public bool IsDevMode { get; private set; }
|
||||
|
||||
public string? DevPluginPath { get; private set; }
|
||||
|
||||
public bool EnableHotReload { get; private set; }
|
||||
|
||||
public IReadOnlyList<string> DevPluginPaths { get; private set; } = Array.Empty<string>();
|
||||
|
||||
private DevPluginOptions() { }
|
||||
|
||||
public static DevPluginOptions Parse(string[] args)
|
||||
{
|
||||
var options = Current;
|
||||
|
||||
options.IsDevMode = TryGetFlag(args, DevModeArgs) ||
|
||||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal) ||
|
||||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ??
|
||||
Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim();
|
||||
|
||||
options.EnableHotReload = TryGetFlag(args, HotReloadArgs);
|
||||
|
||||
if (!options.IsDevMode && !string.IsNullOrWhiteSpace(options.DevPluginPath))
|
||||
{
|
||||
options.IsDevMode = true;
|
||||
}
|
||||
|
||||
options.DevPluginPaths = ResolveDevPluginPaths(options.DevPluginPath);
|
||||
|
||||
if (options.IsDevMode)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"DevPlugin",
|
||||
$"Developer mode enabled. DevPluginPath='{options.DevPluginPath}'; EnableHotReload={options.EnableHotReload}; ResolvedPaths={options.DevPluginPaths.Count}.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
internal void ApplySettingsFromSnapshot(bool isDevMode, string? devPluginPath)
|
||||
{
|
||||
if (isDevMode && !IsDevMode)
|
||||
{
|
||||
IsDevMode = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(devPluginPath) && string.IsNullOrWhiteSpace(DevPluginPath))
|
||||
{
|
||||
DevPluginPath = devPluginPath;
|
||||
}
|
||||
|
||||
var allPaths = new List<string>(DevPluginPaths);
|
||||
if (!string.IsNullOrWhiteSpace(devPluginPath))
|
||||
{
|
||||
foreach (var path in ResolveDevPluginPaths(devPluginPath))
|
||||
{
|
||||
if (!allPaths.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
allPaths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DevPluginPaths = allPaths;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveDevPluginPaths(string? rawPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var paths = rawPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var resolved = new List<string>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (Directory.Exists(fullPath) || File.Exists(fullPath))
|
||||
{
|
||||
resolved.Add(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn("DevPlugin", $"Developer plugin path '{path}' does not exist. It will be skipped.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DevPlugin", $"Failed to resolve developer plugin path '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static bool TryGetFlag(string[] args, string[] names)
|
||||
{
|
||||
return args.Any(arg => names.Any(name => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private static string? TryGetValue(string[] args, string[] names)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (names.Any(name => string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return args[i + 1]?.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace LanMountainDesktop.Services;
|
||||
public enum PluginCatalogSourceKind
|
||||
{
|
||||
Package = 0,
|
||||
Manifest = 1
|
||||
Manifest = 1,
|
||||
DevPlugin = 2
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogEntry(
|
||||
@@ -16,4 +17,5 @@ public sealed record PluginCatalogEntry(
|
||||
bool IsLoaded,
|
||||
string? ErrorMessage,
|
||||
int SettingsPageCount,
|
||||
int WidgetCount);
|
||||
int WidgetCount,
|
||||
bool IsDevPlugin = false);
|
||||
|
||||
@@ -146,7 +146,7 @@ public sealed class PluginLoader
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
|
||||
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory, _options.IsDevMode);
|
||||
AppLogger.Info(
|
||||
"PluginLoader",
|
||||
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
|
||||
@@ -721,13 +721,23 @@ public sealed class PluginLoader
|
||||
private static void ValidatePluginRuntimeAssets(
|
||||
PluginManifest manifest,
|
||||
string assemblyPath,
|
||||
string pluginDirectory)
|
||||
string pluginDirectory,
|
||||
bool isDevMode)
|
||||
{
|
||||
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
|
||||
if (!File.Exists(depsFilePath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
|
||||
if (isDevMode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PluginLoader",
|
||||
$"Plugin '{manifest.Id}' is missing '{Path.GetFileName(depsFilePath)}'. In developer mode this is allowed, but dependency resolution may fail at runtime.");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
|
||||
}
|
||||
}
|
||||
|
||||
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class PluginLoaderOptions
|
||||
|
||||
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
||||
|
||||
public bool IsDevMode { get; init; }
|
||||
|
||||
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
typeof(IPlugin).Assembly.GetName().Name!
|
||||
|
||||
@@ -85,6 +85,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
ApplyPendingPluginDeletions();
|
||||
UnloadInstalledPlugins();
|
||||
MergeDevSettingsFromSnapshot();
|
||||
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
|
||||
|
||||
var disabledPluginIds = GetDisabledPluginIds();
|
||||
@@ -108,19 +109,30 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var isDevPlugin = candidate.SourceKind == PluginCatalogSourceKind.DevPlugin;
|
||||
|
||||
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);
|
||||
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
|
||||
continue;
|
||||
if (isDevPlugin)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"DevPlugin",
|
||||
$"Developer plugin '{candidate.Manifest.Id}' overrides an already-registered plugin from '{candidate.SourcePath}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||
var isEnabled = isDevPlugin || !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||
if (!isEnabled)
|
||||
{
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
@@ -172,6 +184,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
PluginsDirectory,
|
||||
services: _hostServices,
|
||||
hostProperties),
|
||||
PluginCatalogSourceKind.DevPlugin => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: _hostServices,
|
||||
hostProperties),
|
||||
_ => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: _hostServices,
|
||||
@@ -192,7 +208,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
true,
|
||||
null,
|
||||
loadResult.LoadedPlugin.SettingsSections.Count,
|
||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||
loadResult.LoadedPlugin.DesktopComponents.Count,
|
||||
IsDevPlugin: isDevPlugin));
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
|
||||
@@ -208,7 +225,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
false,
|
||||
loadResult.Error?.Message,
|
||||
0,
|
||||
0));
|
||||
0,
|
||||
IsDevPlugin: isDevPlugin));
|
||||
LogPluginFailure("Load", loadResult, treatAsError: true);
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
||||
}
|
||||
@@ -229,6 +247,14 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
var catalogEntry = _catalog.FirstOrDefault(entry =>
|
||||
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
|
||||
if (catalogEntry.IsDevPlugin && !isEnabled)
|
||||
{
|
||||
AppLogger.Warn("DevPlugin", $"Cannot disable developer plugin '{pluginId}'. Developer plugins are always enabled in dev mode.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = LoadAppSettingsSnapshot();
|
||||
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -459,12 +485,74 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
DiscoverDevPluginCandidates(candidates, failures);
|
||||
|
||||
return candidates
|
||||
.OrderBy(candidate => candidate.SourceKind)
|
||||
.OrderByDescending(candidate => candidate.SourceKind)
|
||||
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void DiscoverDevPluginCandidates(List<PluginCandidate> candidates, List<PluginLoadResult> failures)
|
||||
{
|
||||
var devOptions = DevPluginOptions.Current;
|
||||
if (!devOptions.IsDevMode || devOptions.DevPluginPaths.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("DevPlugin", $"Scanning developer plugin paths. Count={devOptions.DevPluginPaths.Count}.");
|
||||
|
||||
foreach (var devPath in devOptions.DevPluginPaths)
|
||||
{
|
||||
if (File.Exists(devPath) && string.Equals(Path.GetExtension(devPath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(devPath);
|
||||
candidates.Add(new PluginCandidate(devPath, manifest, PluginCatalogSourceKind.DevPlugin));
|
||||
AppLogger.Info("DevPlugin", $"Found developer plugin package. PluginId='{manifest.Id}'; Path='{devPath}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failure = PluginLoadResult.Failure(devPath, null, ex);
|
||||
failures.Add(failure);
|
||||
AppLogger.Warn("DevPlugin", $"Failed to read developer plugin package '{devPath}'.", ex);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Directory.Exists(devPath))
|
||||
{
|
||||
var manifestPath = Path.Combine(devPath, PluginSdkInfo.ManifestFileName);
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.DevPlugin));
|
||||
AppLogger.Info("DevPlugin", $"Found developer plugin manifest. PluginId='{manifest.Id}'; Path='{manifestPath}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failure = PluginLoadResult.Failure(manifestPath, null, ex);
|
||||
failures.Add(failure);
|
||||
AppLogger.Warn("DevPlugin", $"Failed to load developer plugin manifest '{manifestPath}'.", ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn("DevPlugin", $"Developer plugin directory '{devPath}' does not contain '{PluginSdkInfo.ManifestFileName}'. Skipping.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
AppLogger.Warn("DevPlugin", $"Developer plugin path '{devPath}' is neither a file nor a directory. Skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
||||
@@ -582,7 +670,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var options = new PluginLoaderOptions();
|
||||
var devOptions = DevPluginOptions.Current;
|
||||
var options = new PluginLoaderOptions { IsDevMode = devOptions.IsDevMode };
|
||||
AddSharedAssembly(options, typeof(App).Assembly);
|
||||
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
||||
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
||||
@@ -614,6 +703,31 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeDevSettingsFromSnapshot()
|
||||
{
|
||||
var devOptions = DevPluginOptions.Current;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = LoadAppSettingsSnapshot();
|
||||
|
||||
if (snapshot.IsDevModeEnabled && !devOptions.IsDevMode)
|
||||
{
|
||||
devOptions.ApplySettingsFromSnapshot(isDevMode: true, devPluginPath: snapshot.DevPluginPath);
|
||||
AppLogger.Info("DevPlugin", $"Developer mode enabled via settings. DevPluginPath='{snapshot.DevPluginPath}'.");
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(snapshot.DevPluginPath) && string.IsNullOrWhiteSpace(devOptions.DevPluginPath))
|
||||
{
|
||||
devOptions.ApplySettingsFromSnapshot(isDevMode: devOptions.IsDevMode, devPluginPath: snapshot.DevPluginPath);
|
||||
AppLogger.Info("DevPlugin", $"Developer plugin path merged from settings. DevPluginPath='{snapshot.DevPluginPath}'.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DevPlugin", "Failed to merge developer settings from snapshot.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||
{
|
||||
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
|
||||
@@ -826,6 +940,13 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
_settingsCatalogService.RemovePluginSections(pluginId);
|
||||
}
|
||||
|
||||
private enum PluginCatalogSourceKind
|
||||
{
|
||||
Package = 0,
|
||||
Manifest = 1,
|
||||
DevPlugin = 2
|
||||
}
|
||||
|
||||
private sealed record PluginCandidate(
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
|
||||
Reference in New Issue
Block a user