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