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