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
26 changes: 26 additions & 0 deletions src/SharpClient.App/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ILogExporter>(_ => 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();
Expand Down
11 changes: 11 additions & 0 deletions src/SharpClient.App/Platforms/Android/MainActivity.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
Expand Down
92 changes: 92 additions & 0 deletions src/SharpClient.App/Services/FileLogStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Globalization;
using System.Text;
using Microsoft.Maui.Storage;

namespace SharpClient.App.Services;

/// <summary>
/// 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
/// <see cref="FileLoggerProvider"/> (framework/app <c>ILogger</c> 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.
/// </summary>
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();

/// <summary>Absolute path to the active log file.</summary>
public string FilePath { get; }

public FileLogStore()
{
var dir = Path.Combine(FileSystem.AppDataDirectory, "logs");
Directory.CreateDirectory(dir);
FilePath = Path.Combine(dir, "sharpclient.log");
}

/// <summary>Appends a single timestamped entry. Never throws — logging must not crash the app.</summary>
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());
}

/// <summary>Records an unhandled exception captured by one of the global hooks.</summary>
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.
}
}
}
59 changes: 59 additions & 0 deletions src/SharpClient.App/Services/FileLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Microsoft.Extensions.Logging;

namespace SharpClient.App.Services;

/// <summary>
/// Routes <c>ILogger</c> output (including Blazor's framework logs — an unhandled component exception
/// is logged at <see cref="LogLevel.Error"/> before the "An unhandled error has occurred" UI appears)
/// into the shared <see cref="FileLogStore"/>. Entries at or above the configured minimum level are kept.
/// </summary>
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>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel && logLevel != LogLevel.None;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}

_store.Append(logLevel.ToString(), _category, formatter(state, exception), exception);
}
}
}
34 changes: 34 additions & 0 deletions src/SharpClient.App/Services/MauiLogExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Maui.ApplicationModel.DataTransfer;
using SharpClient.Core.Diagnostics;

namespace SharpClient.App.Services;

/// <summary>
/// MAUI implementation of <see cref="ILogExporter"/>: hands the on-device log file to the OS share
/// sheet so the user can email it / send it over chat from their phone.
/// </summary>
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),
});
}
}
28 changes: 28 additions & 0 deletions src/SharpClient.Core/Diagnostics/ILogExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace SharpClient.Core.Diagnostics;

/// <summary>
/// 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 <see cref="NoopLogExporter"/>.
/// </summary>
public interface ILogExporter
{
/// <summary>True when a log file exists and can be exported on this platform.</summary>
public bool IsAvailable { get; }

/// <summary>Absolute path to the current log file, or null when unavailable.</summary>
public string? LogPath { get; }

/// <summary>Opens the platform share sheet (or equivalent) so the user can hand off the log.</summary>
public Task ShareAsync();
}

/// <summary>No-op exporter for hosts without a persistent file log (e.g. the Web preview).</summary>
public sealed class NoopLogExporter : ILogExporter
{
public bool IsAvailable => false;

public string? LogPath => null;

public Task ShareAsync() => Task.CompletedTask;
}
39 changes: 39 additions & 0 deletions src/SharpClient.UI/Components/SettingsView.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@using SharpClient.Core.Presentation
@using SharpClient.Core.Diagnostics
@inject ILogExporter LogExporter
@implements IDisposable

<div class="sc-settings">
Expand Down Expand Up @@ -80,15 +82,52 @@
@onchange="e => Vm.Scanlines = (bool)e.Value!" />
</div>
</div>

@if (LogExporter.IsAvailable)
{
<div class="sc-settings-section">
<div class="sc-settings-section-header">Diagnostics</div>
<div class="sc-setting">
<span class="sc-setting-label">Crash &amp; error log</span>
<button type="button" class="sc-rules-btn" @onclick="ExportLogAsync">
@(_exporting ? "Opening…" : "Export log")
</button>
</div>
</div>
}
</div>

@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;
}
6 changes: 6 additions & 0 deletions src/SharpClient.Web/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using SharpClient.Core.Connection;
using SharpClient.Core.Diagnostics;
using SharpClient.Core.Persistence;
using SharpClient.Core.Platform;
using SharpClient.Core.Presentation;
Expand Down Expand Up @@ -32,6 +33,11 @@
// ── Platform notifier ──────────────────────────────────────────────────────
builder.Services.AddSingleton<INotifier, WebNotifier>();

// ── 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<ILogExporter, NoopLogExporter>();

// ── Session management ─────────────────────────────────────────────────────
builder.Services.AddSingleton<SessionManager>();
builder.Services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
Expand Down
Loading
Loading