feat.开发者调试工具

This commit is contained in:
lincube
2026-04-13 08:02:47 +08:00
parent 99a82d64e3
commit 76d13ac024
22 changed files with 616 additions and 30 deletions

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

View File

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

View File

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

View File

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

View File

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