diff --git a/src/SharpClient.App/MauiProgram.cs b/src/SharpClient.App/MauiProgram.cs index 954cf98..52c9083 100644 --- a/src/SharpClient.App/MauiProgram.cs +++ b/src/SharpClient.App/MauiProgram.cs @@ -3,6 +3,7 @@ using Plugin.LocalNotification.Core.Models.AndroidOption; using SharpClient.App.Services; using SharpClient.Core.Connection; +using SharpClient.Core.Diagnostics; using SharpClient.Core.Platform; using SharpClient.Core.Persistence; using SharpClient.Core.Presentation; @@ -37,6 +38,31 @@ public static MauiApp CreateMauiApp() builder.Services.AddMauiBlazorWebView(); + // ── Crash / diagnostics file logging ────────────────────────────── + // A single FileLogStore is the sink for both ILogger output (via FileLoggerProvider — this + // captures Blazor's own Error-level log for an unhandled component exception, the "An + // unhandled error has occurred" case) and the global unhandled-exception hooks below. The + // log lives under the app's private data dir and is exported via MauiLogExporter (Settings → + // Diagnostics → Export log) so the user can hand the stack trace back for diagnosis. + var logStore = new FileLogStore(); + builder.Services.AddSingleton(logStore); + builder.Services.AddSingleton(_ => new MauiLogExporter(logStore)); + builder.Logging.AddProvider(new FileLoggerProvider(logStore)); + + logStore.Append("Information", "App", "SharpClient starting; file logging active."); + + // AndroidEnvironment.UnhandledExceptionRaiser is the reliable catch-all for managed + // exceptions on .NET-Android (it fires for background/network-thread crashes that + // AppDomain.UnhandledException can miss); it is wired in MainActivity, which can reach the + // store through DI. The two hooks below cover the remaining CLR paths. + AppDomain.CurrentDomain.UnhandledException += (_, e) => + logStore.WriteException("AppDomain.UnhandledException", e.ExceptionObject as Exception); + TaskScheduler.UnobservedTaskException += (_, e) => + { + logStore.WriteException("TaskScheduler.UnobservedTaskException", e.Exception); + e.SetObserved(); + }; + #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); diff --git a/src/SharpClient.App/Platforms/Android/MainActivity.cs b/src/SharpClient.App/Platforms/Android/MainActivity.cs index 9958b63..5e548a8 100644 --- a/src/SharpClient.App/Platforms/Android/MainActivity.cs +++ b/src/SharpClient.App/Platforms/Android/MainActivity.cs @@ -1,7 +1,9 @@ using Android.App; using Android.Content.PM; using Android.OS; +using Android.Runtime; using Android.Views; +using SharpClient.App.Services; namespace SharpClient.App; @@ -14,6 +16,15 @@ protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); + // The .NET-Android catch-all for managed exceptions, including ones thrown on + // background/network threads that AppDomain.UnhandledException can miss. The FileLogStore is + // resolved lazily at crash time (the DI container is built by the time a crash can occur). + AndroidEnvironment.UnhandledExceptionRaiser += (_, e) => + { + var store = IPlatformApplication.Current?.Services.GetService(typeof(FileLogStore)) as FileLogStore; + store?.WriteException("AndroidEnvironment.UnhandledExceptionRaiser", e.Exception); + }; + // adjustResize is the prerequisite for keyboard handling. On Android 15+ edge-to-edge it no // longer resizes the window by itself (the system expects us to consume the IME inset), which // WebViewInsetsBridge does via setOnApplyWindowInsetsListener(Type.ime()); we still set it so diff --git a/src/SharpClient.App/Services/FileLogStore.cs b/src/SharpClient.App/Services/FileLogStore.cs new file mode 100644 index 0000000..38d2285 --- /dev/null +++ b/src/SharpClient.App/Services/FileLogStore.cs @@ -0,0 +1,92 @@ +using System.Globalization; +using System.Text; +using Microsoft.Maui.Storage; + +namespace SharpClient.App.Services; + +/// +/// Thread-safe, append-only diagnostics log written to a file under the app's private data directory, +/// with simple size-based rotation (current file + one rolled backup). It is the single sink for both +/// (framework/app ILogger output) and the global +/// unhandled-exception hooks, so a crash that takes the process down still leaves its stack trace on +/// disk for the user to export and hand back. +/// +public sealed class FileLogStore +{ + // Keep the log small enough to share over chat but large enough to hold the lead-up to a crash. + private const long MaxBytes = 512 * 1024; + + private readonly object _gate = new(); + + /// Absolute path to the active log file. + public string FilePath { get; } + + public FileLogStore() + { + var dir = Path.Combine(FileSystem.AppDataDirectory, "logs"); + Directory.CreateDirectory(dir); + FilePath = Path.Combine(dir, "sharpclient.log"); + } + + /// Appends a single timestamped entry. Never throws — logging must not crash the app. + public void Append(string level, string category, string message, Exception? ex = null) + { + var sb = new StringBuilder(256); + sb.Append(DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)); + sb.Append(" [").Append(level).Append("] "); + if (!string.IsNullOrEmpty(category)) + { + sb.Append(category).Append(": "); + } + sb.Append(message); + if (ex is not null) + { + sb.Append('\n').Append(ex); + } + sb.Append('\n'); + Write(sb.ToString()); + } + + /// Records an unhandled exception captured by one of the global hooks. + public void WriteException(string source, Exception? ex) + => Append("CRASH", source, ex?.Message ?? "(no exception object)", ex); + + private void Write(string text) + { + lock (_gate) + { + try + { + RotateIfNeeded(); + File.AppendAllText(FilePath, text); + } + catch + { + // Swallow: a failed log write must never propagate into the running app. + } + } + } + + private void RotateIfNeeded() + { + try + { + var fi = new FileInfo(FilePath); + if (!fi.Exists || fi.Length <= MaxBytes) + { + return; + } + + var backup = FilePath + ".1"; + if (File.Exists(backup)) + { + File.Delete(backup); + } + File.Move(FilePath, backup); + } + catch + { + // If rotation fails, fall through and keep appending to the existing file. + } + } +} diff --git a/src/SharpClient.App/Services/FileLoggerProvider.cs b/src/SharpClient.App/Services/FileLoggerProvider.cs new file mode 100644 index 0000000..47a6e14 --- /dev/null +++ b/src/SharpClient.App/Services/FileLoggerProvider.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; + +namespace SharpClient.App.Services; + +/// +/// Routes ILogger output (including Blazor's framework logs — an unhandled component exception +/// is logged at before the "An unhandled error has occurred" UI appears) +/// into the shared . Entries at or above the configured minimum level are kept. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly FileLogStore _store; + private readonly LogLevel _minLevel; + + public FileLoggerProvider(FileLogStore store, LogLevel minLevel = LogLevel.Information) + { + _store = store; + _minLevel = minLevel; + } + + public ILogger CreateLogger(string categoryName) => new FileLogger(_store, categoryName, _minLevel); + + public void Dispose() + { + } + + private sealed class FileLogger : ILogger + { + private readonly FileLogStore _store; + private readonly string _category; + private readonly LogLevel _minLevel; + + public FileLogger(FileLogStore store, string category, LogLevel minLevel) + { + _store = store; + _category = category; + _minLevel = minLevel; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel && logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + _store.Append(logLevel.ToString(), _category, formatter(state, exception), exception); + } + } +} diff --git a/src/SharpClient.App/Services/MauiLogExporter.cs b/src/SharpClient.App/Services/MauiLogExporter.cs new file mode 100644 index 0000000..3982a09 --- /dev/null +++ b/src/SharpClient.App/Services/MauiLogExporter.cs @@ -0,0 +1,34 @@ +using Microsoft.Maui.ApplicationModel.DataTransfer; +using SharpClient.Core.Diagnostics; + +namespace SharpClient.App.Services; + +/// +/// MAUI implementation of : hands the on-device log file to the OS share +/// sheet so the user can email it / send it over chat from their phone. +/// +public sealed class MauiLogExporter : ILogExporter +{ + private readonly FileLogStore _store; + + public MauiLogExporter(FileLogStore store) => _store = store; + + public bool IsAvailable => true; + + public string? LogPath => _store.FilePath; + + public async Task ShareAsync() + { + // Guarantee the file exists so the share sheet has something to attach even on a fresh install. + if (!File.Exists(_store.FilePath)) + { + _store.Append("Information", "LogExporter", "No log entries recorded yet."); + } + + await Share.Default.RequestAsync(new ShareFileRequest + { + Title = "SharpClient diagnostics log", + File = new ShareFile(_store.FilePath), + }); + } +} diff --git a/src/SharpClient.Core/Diagnostics/ILogExporter.cs b/src/SharpClient.Core/Diagnostics/ILogExporter.cs new file mode 100644 index 0000000..291224c --- /dev/null +++ b/src/SharpClient.Core/Diagnostics/ILogExporter.cs @@ -0,0 +1,28 @@ +namespace SharpClient.Core.Diagnostics; + +/// +/// Exposes the on-device diagnostics log so the UI can offer an "export / share" affordance. +/// Implemented per-platform: the MAUI app shares the real log file via the OS share sheet, while +/// the Blazor Web host has no persistent log and uses . +/// +public interface ILogExporter +{ + /// True when a log file exists and can be exported on this platform. + public bool IsAvailable { get; } + + /// Absolute path to the current log file, or null when unavailable. + public string? LogPath { get; } + + /// Opens the platform share sheet (or equivalent) so the user can hand off the log. + public Task ShareAsync(); +} + +/// No-op exporter for hosts without a persistent file log (e.g. the Web preview). +public sealed class NoopLogExporter : ILogExporter +{ + public bool IsAvailable => false; + + public string? LogPath => null; + + public Task ShareAsync() => Task.CompletedTask; +} diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor index 69a1aef..6bd950b 100644 --- a/src/SharpClient.UI/Components/SettingsView.razor +++ b/src/SharpClient.UI/Components/SettingsView.razor @@ -1,4 +1,6 @@ @using SharpClient.Core.Presentation +@using SharpClient.Core.Diagnostics +@inject ILogExporter LogExporter @implements IDisposable
@@ -80,15 +82,52 @@ @onchange="e => Vm.Scanlines = (bool)e.Value!" />
+ + @if (LogExporter.IsAvailable) + { +
+
Diagnostics
+
+ Crash & error log + +
+
+ } @code { [Parameter] public SettingsViewModel Vm { get; set; } = null!; + private bool _exporting; + protected override void OnInitialized() => Vm.Changed += OnChanged; private void OnChanged() => InvokeAsync(StateHasChanged); + private async Task ExportLogAsync() + { + if (_exporting) + { + return; + } + + _exporting = true; + try + { + await LogExporter.ShareAsync(); + } + catch + { + // Sharing is best-effort; never let a failed share crash the settings screen. + } + finally + { + _exporting = false; + } + } + public void Dispose() => Vm.Changed -= OnChanged; } diff --git a/src/SharpClient.Web/Program.cs b/src/SharpClient.Web/Program.cs index f7a2b8f..206cc4f 100644 --- a/src/SharpClient.Web/Program.cs +++ b/src/SharpClient.Web/Program.cs @@ -1,4 +1,5 @@ using SharpClient.Core.Connection; +using SharpClient.Core.Diagnostics; using SharpClient.Core.Persistence; using SharpClient.Core.Platform; using SharpClient.Core.Presentation; @@ -32,6 +33,11 @@ // ── Platform notifier ────────────────────────────────────────────────────── builder.Services.AddSingleton(); +// ── Diagnostics ───────────────────────────────────────────────────────────── +// The Web preview has no persistent file log; the no-op exporter keeps SettingsView's +// ILogExporter injection satisfiable and hides the export affordance (IsAvailable == false). +builder.Services.AddSingleton(); + // ── Session management ───────────────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/tests/SharpClient.UI.Tests/SettingsViewTests.cs b/tests/SharpClient.UI.Tests/SettingsViewTests.cs index 44b2025..0c958b2 100644 --- a/tests/SharpClient.UI.Tests/SettingsViewTests.cs +++ b/tests/SharpClient.UI.Tests/SettingsViewTests.cs @@ -1,4 +1,6 @@ using Bunit; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Diagnostics; using SharpClient.Core.Presentation; using SharpClient.UI.Components; @@ -20,10 +22,19 @@ public sealed class SettingsViewTests { private static SettingsViewModel MakeVm() => new(new LocalFakePrefs()); + // SettingsView injects ILogExporter; register a no-op so renders resolve. IsAvailable == false + // hides the Diagnostics section, leaving the assertions below (font/accent/slider counts) intact. + private static BunitContext NewContext() + { + var ctx = new BunitContext(); + ctx.Services.AddSingleton(new NoopLogExporter()); + return ctx; + } + [Test] public async Task RendersAllFourFontOptions() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); @@ -35,7 +46,7 @@ public async Task RendersAllFourFontOptions() [Test] public async Task RendersAllFiveAccentSwatches() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); @@ -47,7 +58,7 @@ public async Task RendersAllFiveAccentSwatches() [Test] public async Task RendersGlowAndScanlineToggles() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); @@ -60,7 +71,7 @@ public async Task RendersGlowAndScanlineToggles() [Test] public async Task ClickingAccentSwatchUpdatesVmAccent() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); vm.Accent = "#9b7ed4"; // start with default @@ -76,7 +87,7 @@ public async Task ClickingAccentSwatchUpdatesVmAccent() [Test] public async Task ClickingFontOptionUpdatesVmFont() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); @@ -91,7 +102,7 @@ public async Task ClickingFontOptionUpdatesVmFont() [Test] public async Task MinColumnsSliderRendersWithCorrectValue() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); vm.MinColumns = 90; @@ -105,7 +116,7 @@ public async Task MinColumnsSliderRendersWithCorrectValue() [Test] public async Task MaxFontSizeSliderRendersWithCorrectValue() { - using var ctx = new BunitContext(); + using var ctx = NewContext(); var vm = MakeVm(); vm.MaxFontSize = 16;