Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion GMConverter.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
15 changes: 14 additions & 1 deletion GMConverter.UI/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Avalonia;
using GMConverter.Plugins;
using GMConverter.UI.Services;
using Microsoft.Extensions.Logging;

namespace GMConverter.UI;

Expand All @@ -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);
}
Expand Down
97 changes: 97 additions & 0 deletions GMConverter.UI/Services/PluginLogFileProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Globalization;
using Microsoft.Extensions.Logging;

namespace GMConverter.UI.Services;

/// <summary>
/// Minimal file-backed <see cref="ILoggerProvider"/> used during host startup before Avalonia
/// is ready (and therefore before <see cref="UiLogSink"/> 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.
/// </summary>
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>(TState state)
where TState : notnull
{
return null;
}

public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= LogLevel.Information;
}

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> 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);
Comment thread
dhkatz marked this conversation as resolved.
Dismissed
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort telemetry — filesystem failures here must never block plugin load.
_ = ex;
}
}
}
}
}
Loading