From 114b65b0646cf9d2c528bf0e63e6823bcab7ca54 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Mon, 25 May 2026 04:29:16 -0700 Subject: [PATCH] feat(plugins): wire host logger factories into PluginHost.Initialize so plugin load failures stop being silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both CLI and UI previously called PluginHost.Initialize without a logger factory, which fell back to NullLoggerFactory and dropped every plugin discovery/load event on the floor. The recent UE deploy regression (#26) was silent for that reason — plugins failed to load and the user only noticed because the Explorer dropdown was missing entries. CLI - Constructs a console-backed LoggerFactory at Information level and passes it to PluginHost.Initialize. Plugin load events flow into stderr alongside the rest of the CLI's output, matching how conversion progress is already surfaced. UI - Adds GMConverter.UI/Services/PluginLogFileProvider.cs — a minimal file-backed ILoggerProvider that writes to %TEMP%/GMConverter.Plugins.log. The provider is Avalonia-readiness-independent (plugin load runs before the UI thread dispatcher exists, so it cannot route through UiLogSink at this point in the lifecycle). Plugin load failures land in a known file the user (and future support requests) can grep when the in-app behavior doesn't match expectations. - Plumbing the file into UiLogSink so plugin events show in the in-app console panel is a worthwhile follow-up but requires restructuring UiLogSink's lifecycle (it currently field-initializes inside MainWindowViewModel) and is out of scope here. Verified: - CLI: running with any input now prints "info: GMConverter.Plugins.PluginLoader[2001] Loaded plugin gmconverter.unrealengine v0.1.0" before the conversion error or success message. - UI: %TEMP%/GMConverter.Plugins.log now contains a startup header plus the Loaded plugin entry every time the UI launches. Co-Authored-By: Claude Opus 4.7 (1M context) --- GMConverter.CLI/Program.cs | 11 ++- GMConverter.UI/Program.cs | 15 ++- .../Services/PluginLogFileProvider.cs | 97 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 GMConverter.UI/Services/PluginLogFileProvider.cs diff --git a/GMConverter.CLI/Program.cs b/GMConverter.CLI/Program.cs index 978e5e5..1ba628b 100644 --- a/GMConverter.CLI/Program.cs +++ b/GMConverter.CLI/Program.cs @@ -17,7 +17,16 @@ public static int Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; - PluginHost.Initialize(PluginHost.DefaultDirectory); + // Plugin load is observability that belongs in the same stream as the rest of the CLI's + // output. A console logger at Information level surfaces plugin discovery + load failures + // alongside conversion progress, so a user running the CLI sees plugin issues immediately + // instead of hunting for them. + using var pluginLoggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Information); + builder.AddConsole(); + }); + PluginHost.Initialize(PluginHost.DefaultDirectory, pluginLoggerFactory); var rootCommand = CreateRootCommand(); diff --git a/GMConverter.UI/Program.cs b/GMConverter.UI/Program.cs index 3f20b3c..047e022 100644 --- a/GMConverter.UI/Program.cs +++ b/GMConverter.UI/Program.cs @@ -1,5 +1,7 @@ using Avalonia; using GMConverter.Plugins; +using GMConverter.UI.Services; +using Microsoft.Extensions.Logging; namespace GMConverter.UI; @@ -19,7 +21,18 @@ public static void Main(string[] args) licenseType: "OpenSourceLicense", license: "A543-105E-0047-F209-CC6E-32CD-D155-4F0F-11AD-2706-9E68-7A5B-4945-D56B-9F96-C0CB-A45F-9339-DB9E-F4CE-C84A-DFAD-82B5-B095-1B"); - PluginHost.Initialize(PluginHost.DefaultDirectory); + // Plugin load happens before Avalonia is initialized, so we cannot route plugin diagnostics + // through UiLogSink (which posts to Dispatcher.UIThread). A simple file-backed logger + // captures plugin discovery + load failures into %TEMP%\GMConverter.Plugins.log so the + // user has a place to look when a plugin fails to register importers/exporters/explorers + // at startup. Piping plugin events into the in-app console once Avalonia is ready is a + // follow-up. + using var pluginLoggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Information); + builder.AddProvider(new PluginLogFileProvider()); + }); + PluginHost.Initialize(PluginHost.DefaultDirectory, pluginLoggerFactory); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } diff --git a/GMConverter.UI/Services/PluginLogFileProvider.cs b/GMConverter.UI/Services/PluginLogFileProvider.cs new file mode 100644 index 0000000..d083fd2 --- /dev/null +++ b/GMConverter.UI/Services/PluginLogFileProvider.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace GMConverter.UI.Services; + +/// +/// Minimal file-backed used during host startup before Avalonia +/// is ready (and therefore before can post entries to the UI thread). +/// Plugin discovery and load failures land here so they aren't silent if a plugin fails to load +/// at startup. The log path is exposed as a static property so the rest of the app can surface +/// it (e.g. in an "About" pane) when we eventually wire that up. +/// +internal sealed class PluginLogFileProvider : ILoggerProvider +{ + public static string LogPath { get; } = + Path.Join(Path.GetTempPath(), "GMConverter.Plugins.log"); + + private static readonly object _writeLock = new(); + + static PluginLogFileProvider() + { + try + { + File.WriteAllText( + LogPath, + FormattableString.Invariant( + $"# GMConverter plugin log | pid {Environment.ProcessId} | started {DateTimeOffset.Now:O}{Environment.NewLine}")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Telemetry is best-effort; never propagate filesystem failures from instrumentation. + _ = ex; + } + } + + public ILogger CreateLogger(string categoryName) + { + return new FileLogger(categoryName); + } + + public void Dispose() + { + } + + private sealed class FileLogger(string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Information; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var line = string.Format( + CultureInfo.InvariantCulture, + "[{0:O}] [{1,-11}] {2}: {3}", + DateTimeOffset.Now, + logLevel, + categoryName, + message); + + lock (_writeLock) + { + try + { + File.AppendAllText(LogPath, line + Environment.NewLine); + if (exception is not null) + { + File.AppendAllText(LogPath, exception.ToString() + Environment.NewLine); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort telemetry — filesystem failures here must never block plugin load. + _ = ex; + } + } + } + } +}