From 4679ee006fc67e65f8af282306cddb93cc2c0e6f Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 12 Mar 2026 12:25:22 +0800 Subject: [PATCH] 0.5.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我认为很稳定了,后面就要开始弄插件不稳定了 --- .gitignore | 10 + .../ComponentSettingsServiceTests.cs | 187 +++++++++++++++ .../LanMountainDesktop.Tests.csproj | 21 ++ .../UiExceptionGuardTests.cs | 43 ++++ LanMountainDesktop.slnx | 1 + LanMountainDesktop/App.axaml.cs | 35 +++ LanMountainDesktop/Localization/en-US.json | 6 +- LanMountainDesktop/Localization/zh-CN.json | 6 +- LanMountainDesktop/Program.cs | 15 +- LanMountainDesktop/Properties/AssemblyInfo.cs | 3 + .../Services/ComponentSettingsService.cs | 146 +++++++++-- .../Services/UiExceptionGuard.cs | 80 +++++++ .../Views/Components/BrowserWidget.axaml.cs | 178 ++++++++++---- .../Components/DesktopComponentFailureView.cs | 226 ++++++++++++++++++ .../Views/MainWindow.ComponentSystem.cs | 70 ++++-- .../Views/MainWindow.SingleInstanceNotice.cs | 73 +++--- LanMountainDesktop/Views/MainWindow.axaml | 45 ---- .../plugins/PluginMarketEmbeddedView.cs | 225 ++++++++++++++--- .../plugins/PluginMarketIndexService.cs | 12 +- .../plugins/PluginSettingsPage.Host.cs | 20 +- 20 files changed, 1197 insertions(+), 205 deletions(-) create mode 100644 LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs create mode 100644 LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj create mode 100644 LanMountainDesktop.Tests/UiExceptionGuardTests.cs create mode 100644 LanMountainDesktop/Properties/AssemblyInfo.cs create mode 100644 LanMountainDesktop/Services/UiExceptionGuard.cs create mode 100644 LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs diff --git a/.gitignore b/.gitignore index f271b61..f9aac1e 100644 --- a/.gitignore +++ b/.gitignore @@ -502,3 +502,13 @@ nul /validator-restore.log /temp_old_main.axaml /temp_old_main_utf8.axaml + +# LanMountainDesktop local packaging outputs +/build-installer/ +/build-deb/ +/dmg-temp/ +/release-files/ +/LanMountainDesktop.app/ +/*.deb +/*.dmg +/*.AppImage diff --git a/LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs b/LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs new file mode 100644 index 0000000..f752fa7 --- /dev/null +++ b/LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ComponentSettingsServiceTests +{ + [Fact] + public void Load_MigratesLegacySnapshotFileToCanonicalDocument() + { + using var sandbox = new ComponentSettingsSandbox(); + File.WriteAllText( + sandbox.SettingsPath, + """ + { + "DesktopClockSecondHandMode": "Sweep", + "ImportedClassSchedules": [ + { + "Id": "spring-2026", + "DisplayName": "Spring 2026", + "FilePath": "C:\\Schedules\\spring-2026.yaml" + } + ], + "ActiveImportedClassScheduleId": "spring-2026" + } + """); + + var service = sandbox.CreateService(); + + var snapshot = service.Load(); + + Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode); + Assert.Single(snapshot.ImportedClassSchedules); + + using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath)); + Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings)); + Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString()); + Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _)); + } + + [Fact] + public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument() + { + using var sandbox = new ComponentSettingsSandbox(); + File.WriteAllText( + sandbox.SettingsPath, + """ + { + "DefaultSettings": { + "DesktopClockSecondHandMode": "Tick" + }, + "InstanceSettings": { + "DesktopClock::clock-2x2": { + "DesktopClockSecondHandMode": "Sweep" + } + }, + "PluginSettings": { + "DesktopClock::clock-2x2": { + "SampleFlag": true + } + } + } + """); + + var service = sandbox.CreateService(); + + var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2"); + var pluginSettings = service.LoadPluginSettings("DesktopClock", "clock-2x2"); + + Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode); + Assert.True(pluginSettings.SampleFlag); + + using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath)); + Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings)); + Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings)); + Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString()); + Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _)); + } + + [Fact] + public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService() + { + using var sandbox = new ComponentSettingsSandbox(); + var service = sandbox.CreateService(); + + service.SaveForComponent( + "DesktopClock", + "clock-2x2", + new ComponentSettingsSnapshot + { + DesktopClockSecondHandMode = "Sweep" + }); + service.SaveForComponent( + "DesktopClassSchedule", + "class-schedule-2x2", + new ComponentSettingsSnapshot + { + ImportedClassSchedules = + [ + new ImportedClassScheduleSnapshot + { + Id = "spring-2026", + DisplayName = "Spring 2026", + FilePath = "C:\\Schedules\\spring-2026.yaml" + } + ], + ActiveImportedClassScheduleId = "spring-2026" + }); + service.SavePluginSettings( + "DesktopClassSchedule", + "class-schedule-2x2", + new SamplePluginSettings + { + SampleFlag = true, + Title = "schedule-settings" + }); + + ComponentSettingsService.ResetCacheForTests(); + var reloadedService = sandbox.CreateService(); + + var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2"); + var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2"); + var pluginSettings = reloadedService.LoadPluginSettings( + "DesktopClassSchedule", + "class-schedule-2x2"); + + Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode); + Assert.Single(classScheduleSnapshot.ImportedClassSchedules); + Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId); + Assert.True(pluginSettings.SampleFlag); + Assert.Equal("schedule-settings", pluginSettings.Title); + + using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath)); + Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings)); + Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _)); + Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _)); + Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode)); + Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _)); + } + + private sealed class ComponentSettingsSandbox : IDisposable + { + private readonly string _directoryPath = Path.Combine( + Path.GetTempPath(), + "LanMountainDesktop.ComponentSettingsTests", + Guid.NewGuid().ToString("N")); + + public ComponentSettingsSandbox() + { + Directory.CreateDirectory(_directoryPath); + ComponentSettingsService.ResetCacheForTests(); + } + + public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json"); + + public ComponentSettingsService CreateService() + { + return new ComponentSettingsService(_directoryPath); + } + + public void Dispose() + { + ComponentSettingsService.ResetCacheForTests(); + + try + { + if (Directory.Exists(_directoryPath)) + { + Directory.Delete(_directoryPath, true); + } + } + catch + { + // Temporary test directories are best-effort cleanup. + } + } + } + + private sealed class SamplePluginSettings + { + public bool SampleFlag { get; set; } + + public string Title { get; set; } = string.Empty; + } +} diff --git a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj new file mode 100644 index 0000000..da890d9 --- /dev/null +++ b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/LanMountainDesktop.Tests/UiExceptionGuardTests.cs b/LanMountainDesktop.Tests/UiExceptionGuardTests.cs new file mode 100644 index 0000000..de751ef --- /dev/null +++ b/LanMountainDesktop.Tests/UiExceptionGuardTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class UiExceptionGuardTests +{ + [Fact] + public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler() + { + var handlerCalled = false; + + await UiExceptionGuard.RunGuardedUiActionAsync( + () => throw new InvalidOperationException("boom"), + "UnitTest.NonFatal", + onHandledException: ex => + { + handlerCalled = ex is InvalidOperationException; + return Task.CompletedTask; + }); + + Assert.True(handlerCalled); + } + + [Fact] + public async Task RunGuardedUiActionAsync_RethrowsFatalException() + { + await Assert.ThrowsAsync(() => + UiExceptionGuard.RunGuardedUiActionAsync( + () => throw new OutOfMemoryException("fatal"), + "UnitTest.Fatal")); + } + + [Fact] + public void IsFatalException_ReturnsExpectedClassification() + { + Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException())); + Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException())); + Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException())); + } +} diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx index 4daf116..20053cf 100644 --- a/LanMountainDesktop.slnx +++ b/LanMountainDesktop.slnx @@ -3,4 +3,5 @@ + diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 0b791f7..22e5397 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -8,6 +8,7 @@ using System.Linq; using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Services; using LanMountainDesktop.ViewModels; using LanMountainDesktop.Views; @@ -66,6 +67,7 @@ public partial class App : Application DataContext = new MainWindowViewModel(), }; AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}"); + LogBrowserStartupDiagnostics(); CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow); } @@ -268,6 +270,11 @@ public partial class App : Application mainWindow.WindowState = WindowState.Normal; } + if (mainWindow.WindowState != WindowState.FullScreen) + { + mainWindow.WindowState = WindowState.FullScreen; + } + mainWindow.Activate(); mainWindow.Topmost = true; mainWindow.Topmost = false; @@ -335,6 +342,34 @@ public partial class App : Application DisposeTrayIcon(); } + private void LogBrowserStartupDiagnostics() + { + try + { + var snapshot = new DesktopLayoutSettingsService().Load(); + var browserPlacements = snapshot.DesktopComponentPlacements + .Where(placement => string.Equals( + placement.ComponentId, + BuiltInComponentIds.DesktopBrowser, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + var runtimeAvailability = WebView2RuntimeProbe.GetAvailability(); + + AppLogger.Info( + "StartupDiagnostics", + $"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " + + $"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " + + $"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " + + $"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " + + $"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " + + $"WebViewRuntimeMessage={runtimeAvailability.Message}"); + } + catch (Exception ex) + { + AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex); + } + } + private string L(string key, string fallback) { var snapshot = _appSettingsService.Load(); diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 9a6ed2e..7a60ca1 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -744,9 +744,9 @@ "placement.stretch": "Stretch", "placement.center": "Center", "placement.tile": "Tile", - "single_instance.notice.title": "App already open", - "single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.", - "single_instance.notice.button": "Got it" + "single_instance.notice.title": "App already running", + "single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.", + "single_instance.notice.button": "OK" } diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index ef8a059..9ba6b7e 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -744,9 +744,9 @@ "placement.stretch": "拉伸", "placement.center": "居中", "placement.tile": "平铺", - "single_instance.notice.title": "应用已打开", - "single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。", - "single_instance.notice.button": "知道了" + "single_instance.notice.title": "应用已经运行", + "single_instance.notice.description": "应用已经运行,无需多次点击打开。", + "single_instance.notice.button": "确定" } diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 2291b0f..f0522fd 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -18,10 +18,19 @@ sealed class Program { AppLogger.Initialize(); RegisterGlobalExceptionLogging(); + var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args); - using var singleInstance = AcquireSingleInstance(args); + using var singleInstance = AcquireSingleInstance(restartParentProcessId); if (!singleInstance.IsPrimaryInstance) { + if (restartParentProcessId is not null) + { + AppLogger.Warn( + "Startup", + $"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt."); + return; + } + AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running."); _ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); return; @@ -73,9 +82,8 @@ sealed class Program return builder; } - private static SingleInstanceService AcquireSingleInstance(string[] args) + private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId) { - var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args); var singleInstance = SingleInstanceService.CreateDefault(); if (singleInstance.IsPrimaryInstance || restartParentProcessId is null) { @@ -156,6 +164,7 @@ sealed class Program TaskScheduler.UnobservedTaskException += (_, eventArgs) => { AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception); + eventArgs.SetObserved(); }; } } diff --git a/LanMountainDesktop/Properties/AssemblyInfo.cs b/LanMountainDesktop/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..944b2df --- /dev/null +++ b/LanMountainDesktop/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")] diff --git a/LanMountainDesktop/Services/ComponentSettingsService.cs b/LanMountainDesktop/Services/ComponentSettingsService.cs index a132d80..0d11cd5 100644 --- a/LanMountainDesktop/Services/ComponentSettingsService.cs +++ b/LanMountainDesktop/Services/ComponentSettingsService.cs @@ -12,6 +12,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore { private static readonly JsonSerializerOptions SerializerOptions = new() { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; @@ -29,9 +31,19 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore private string _scopedPlacementId = string.Empty; public ComponentSettingsService() + : this(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop")) { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var settingsDirectory = Path.Combine(appData, "LanMountainDesktop"); + } + + internal ComponentSettingsService(string settingsDirectory) + { + if (string.IsNullOrWhiteSpace(settingsDirectory)) + { + throw new ArgumentException("Settings directory cannot be null or whitespace.", nameof(settingsDirectory)); + } + _settingsPath = Path.Combine(settingsDirectory, "component-settings.json"); _legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json"); } @@ -345,10 +357,11 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore } ComponentSettingsDocumentSnapshot loadedSnapshot; - var loadedFromLegacy = false; + var loadDetails = ComponentSettingsLoadDetails.Empty; if (hasFile) { - loadedSnapshot = LoadSnapshotFromDisk(); + loadDetails = LoadSnapshotFromDisk(); + loadedSnapshot = loadDetails.Snapshot; } else if (TryLoadLegacySnapshot(out var migratedSnapshot)) { @@ -356,7 +369,10 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore { DefaultSettings = NormalizeSnapshot(migratedSnapshot) }; - loadedFromLegacy = true; + loadDetails = new ComponentSettingsLoadDetails( + loadedSnapshot, + ComponentSettingsDocumentFormat.LegacySnapshot, + true); } else { @@ -364,40 +380,44 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore } var normalizedSnapshot = NormalizeDocument(loadedSnapshot); - if (loadedFromLegacy) + if (loadDetails.ShouldRewriteToCanonical) { writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot); } + LogLoadDetails(loadDetails.Format, loadDetails.ShouldRewriteToCanonical, normalizedSnapshot); UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc); return normalizedSnapshot.Clone(); } - private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk() + private ComponentSettingsLoadDetails LoadSnapshotFromDisk() { try { var json = File.ReadAllText(_settingsPath); using var document = JsonDocument.Parse(json); - if (document.RootElement.ValueKind == JsonValueKind.Object && - (document.RootElement.TryGetProperty("defaultSettings", out _) || - document.RootElement.TryGetProperty("instanceSettings", out _) || - document.RootElement.TryGetProperty("pluginSettings", out _))) + if (TryGetDocumentFormat(document.RootElement, out var format)) { var snapshot = JsonSerializer.Deserialize(json, SerializerOptions); - return NormalizeDocument(snapshot); + return new ComponentSettingsLoadDetails( + snapshot ?? new ComponentSettingsDocumentSnapshot(), + format, + format == ComponentSettingsDocumentFormat.PascalCaseDocument); } var legacySnapshot = JsonSerializer.Deserialize(json, SerializerOptions); - return new ComponentSettingsDocumentSnapshot - { - DefaultSettings = NormalizeSnapshot(legacySnapshot) - }; + return new ComponentSettingsLoadDetails( + new ComponentSettingsDocumentSnapshot + { + DefaultSettings = NormalizeSnapshot(legacySnapshot) + }, + ComponentSettingsDocumentFormat.LegacySnapshot, + true); } catch (Exception ex) { AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex); - return new ComponentSettingsDocumentSnapshot(); + return ComponentSettingsLoadDetails.Empty; } } @@ -684,6 +704,79 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore _lastProbeUtc = probeTimeUtc; } + internal static void ResetCacheForTests() + { + lock (CacheGate) + { + _cachedPath = null; + _cachedSnapshot = null; + _cachedWriteTimeUtc = DateTime.MinValue; + _lastProbeUtc = DateTime.MinValue; + } + } + + private void LogLoadDetails( + ComponentSettingsDocumentFormat format, + bool rewroteToCanonical, + ComponentSettingsDocumentSnapshot snapshot) + { + AppLogger.Info( + "ComponentSettings", + $"Loaded component settings document. Format={format}; RewroteToCanonical={rewroteToCanonical}; " + + $"InstanceSettings={snapshot.InstanceSettings.Count}; PluginSettings={snapshot.PluginSettings.Count}; Path={_settingsPath}"); + } + + private static bool TryGetDocumentFormat( + JsonElement rootElement, + out ComponentSettingsDocumentFormat format) + { + format = ComponentSettingsDocumentFormat.EmptyDocument; + if (rootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + var hasDocumentProperties = false; + var requiresCanonicalRewrite = false; + foreach (var property in rootElement.EnumerateObject()) + { + if (!IsDocumentPropertyName(property.Name)) + { + continue; + } + + hasDocumentProperties = true; + if (!IsCanonicalDocumentPropertyName(property.Name)) + { + requiresCanonicalRewrite = true; + } + } + + if (!hasDocumentProperties) + { + return false; + } + + format = requiresCanonicalRewrite + ? ComponentSettingsDocumentFormat.PascalCaseDocument + : ComponentSettingsDocumentFormat.CanonicalDocument; + return true; + } + + private static bool IsDocumentPropertyName(string propertyName) + { + return string.Equals(propertyName, "defaultSettings", StringComparison.OrdinalIgnoreCase) || + string.Equals(propertyName, "instanceSettings", StringComparison.OrdinalIgnoreCase) || + string.Equals(propertyName, "pluginSettings", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsCanonicalDocumentPropertyName(string propertyName) + { + return string.Equals(propertyName, "defaultSettings", StringComparison.Ordinal) || + string.Equals(propertyName, "instanceSettings", StringComparison.Ordinal) || + string.Equals(propertyName, "pluginSettings", StringComparison.Ordinal); + } + private sealed class ComponentSettingsDocumentSnapshot { public ComponentSettingsSnapshot DefaultSettings { get; set; } = new(); @@ -771,4 +864,23 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated; } + + private readonly record struct ComponentSettingsLoadDetails( + ComponentSettingsDocumentSnapshot Snapshot, + ComponentSettingsDocumentFormat Format, + bool ShouldRewriteToCanonical) + { + public static ComponentSettingsLoadDetails Empty { get; } = new( + new ComponentSettingsDocumentSnapshot(), + ComponentSettingsDocumentFormat.EmptyDocument, + false); + } + + private enum ComponentSettingsDocumentFormat + { + EmptyDocument, + CanonicalDocument, + PascalCaseDocument, + LegacySnapshot + } } diff --git a/LanMountainDesktop/Services/UiExceptionGuard.cs b/LanMountainDesktop/Services/UiExceptionGuard.cs new file mode 100644 index 0000000..ecba9ab --- /dev/null +++ b/LanMountainDesktop/Services/UiExceptionGuard.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +internal static class UiExceptionGuard +{ + public static bool IsFatalException(Exception? exception) + { + return exception is OutOfMemoryException or AccessViolationException or StackOverflowException; + } + + public static void FireAndForgetGuarded( + Func action, + string actionName, + string? context = null, + Func? onHandledException = null) + { + _ = RunGuardedUiActionAsync(action, actionName, context, onHandledException); + } + + public static async Task RunGuardedUiActionAsync( + Func action, + string actionName, + string? context = null, + Func? onHandledException = null) + { + ArgumentNullException.ThrowIfNull(action); + + try + { + await action(); + } + catch (Exception ex) when (!IsFatalException(ex)) + { + LogHandledException("GuardedUiAction", actionName, ex, context, isFatal: false); + if (onHandledException is not null) + { + try + { + await onHandledException(ex); + } + catch (Exception handlerEx) when (!IsFatalException(handlerEx)) + { + LogHandledException("GuardedUiActionHandler", actionName, handlerEx, context, isFatal: false); + } + } + } + } + + public static string BuildContext(params (string Key, object? Value)[] parts) + { + if (parts is null || parts.Length == 0) + { + return string.Empty; + } + + return string.Join( + "; ", + Array.ConvertAll(parts, part => $"{part.Key}={part.Value ?? ""}")); + } + + private static void LogHandledException( + string category, + string actionName, + Exception exception, + string? context, + bool isFatal) + { + var message = + $"Action={actionName}; ExceptionType={exception.GetType().FullName}; IsFatal={isFatal}; Context={context ?? string.Empty}"; + if (isFatal) + { + AppLogger.Critical(category, message, exception); + return; + } + + AppLogger.Warn(category, message, exception); + } +} diff --git a/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs b/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs index 6fc4b51..1911f0f 100644 --- a/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs @@ -6,21 +6,27 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Styling; using AvaloniaWebView; +using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Services; using WebViewCore.Events; namespace LanMountainDesktop.Views.Components; -public partial class BrowserWidget : UserControl, IDesktopComponentWidget - , IDesktopPageVisibilityAwareComponentWidget, IDisposable +public partial class BrowserWidget : UserControl, IDesktopComponentWidget, + IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable { private static readonly Uri DefaultHomeUri = new("https://www.bing.com"); + private double _currentCellSize = 48; + private string _componentId = BuiltInComponentIds.DesktopBrowser; + private string _placementId = string.Empty; private bool? _isNightModeApplied; private Uri _lastKnownUri = DefaultHomeUri; private bool _isOnActiveDesktopPage; + private bool _isAttachedToVisualTree; private bool _isEditMode; private bool _isWebViewActive = true; + private bool _isWebViewFaulted; private readonly WebView2RuntimeAvailability _runtimeAvailability; private bool _isDisposed; @@ -45,8 +51,8 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget ApplyRuntimeUnavailableState(); } + AddressTextBox.Text = DefaultHomeUri.ToString(); UpdateWebViewActiveState(); - NavigateTo(DefaultHomeUri); } public void Dispose() @@ -74,17 +80,15 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget _currentCellSize = Math.Max(1, cellSize); RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28)); - RootBorder.Padding = new Thickness( - Math.Clamp(_currentCellSize * 0.20, 8, 18)); + RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.20, 8, 18)); WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22)); AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20)); AddressBarBorder.Padding = new Thickness(8, 6); - var rowSpacing = 8d; if (RootBorder.Child is Grid rootGrid) { - rootGrid.RowSpacing = rowSpacing; + rootGrid.RowSpacing = 8d; } var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36); @@ -111,16 +115,33 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget AddressTextBox.Height = buttonSize; } + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _isOnActiveDesktopPage = isOnActivePage; + _isEditMode = isEditMode; + UpdateWebViewActiveState(); + } + + public void SetComponentPlacementContext(string componentId, string? placementId) + { + _componentId = string.IsNullOrWhiteSpace(componentId) + ? BuiltInComponentIds.DesktopBrowser + : componentId.Trim(); + _placementId = placementId?.Trim() ?? string.Empty; + } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { + _isAttachedToVisualTree = true; ApplyTheme(force: true); UpdateWebViewActiveState(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { + _isAttachedToVisualTree = false; _isOnActiveDesktopPage = false; - UpdateWebViewActiveState(); + DeactivateWebView(clearUrl: false); } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -202,28 +223,20 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget private void OnRefreshButtonClick(object? sender, RoutedEventArgs e) { - if (!_runtimeAvailability.IsAvailable) + if (!CanUseWebView()) { return; } - if (!_isWebViewActive) + if (!TryReloadWebView("Refresh")) { - return; + TryNavigate(DefaultHomeUri, "RefreshFallback"); } - - if (BrowserWebView.Url is not null) - { - BrowserWebView.Reload(); - return; - } - - NavigateTo(DefaultHomeUri); } private void OnGoButtonClick(object? sender, RoutedEventArgs e) { - if (!_runtimeAvailability.IsAvailable) + if (!CanUseWebView()) { return; } @@ -233,7 +246,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e) { - if (!_runtimeAvailability.IsAvailable) + if (!CanUseWebView()) { return; } @@ -249,7 +262,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget private void NavigateFromAddressBar() { - if (!_runtimeAvailability.IsAvailable) + if (!CanUseWebView()) { return; } @@ -269,7 +282,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget AddressTextBox.Text = uri.ToString(); if (_isWebViewActive) { - BrowserWebView.Url = uri; + TryNavigate(uri, "NavigateTo"); } } @@ -284,25 +297,16 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget AddressTextBox.Text = e.Url.ToString(); } - public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) - { - _isOnActiveDesktopPage = isOnActivePage; - _isEditMode = isEditMode; - UpdateWebViewActiveState(); - } - private void UpdateWebViewActiveState() { - if (!_runtimeAvailability.IsAvailable) + if (!_runtimeAvailability.IsAvailable || _isWebViewFaulted) { _isWebViewActive = false; - BrowserWebView.Url = null; - BrowserWebView.IsVisible = false; - BrowserWebView.IsHitTestVisible = false; + ApplyRuntimeUnavailableState(); return; } - var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible; + var shouldBeActive = _isAttachedToVisualTree && _isOnActiveDesktopPage && !_isEditMode && IsVisible; if (_isWebViewActive == shouldBeActive) { return; @@ -311,40 +315,118 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget _isWebViewActive = shouldBeActive; if (!_isWebViewActive) { - if (BrowserWebView.Url is Uri currentUri) - { - _lastKnownUri = currentUri; - } + DeactivateWebView(clearUrl: false); + return; + } - BrowserWebView.IsHitTestVisible = false; - BrowserWebView.IsVisible = false; - BrowserWebView.Url = null; + ActivateWebView(); + } + + private void ActivateWebView() + { + if (_isWebViewFaulted || !_runtimeAvailability.IsAvailable) + { + ApplyRuntimeUnavailableState(); return; } BrowserWebView.IsVisible = true; BrowserWebView.IsHitTestVisible = true; - BrowserWebView.Url = _lastKnownUri; + RefreshButton.IsEnabled = true; + GoButton.IsEnabled = true; + AddressTextBox.IsEnabled = true; + UnavailableOverlay.IsVisible = false; + + TryNavigate(_lastKnownUri, "Activate"); + } + + private void DeactivateWebView(bool clearUrl) + { + BrowserWebView.IsHitTestVisible = false; + BrowserWebView.IsVisible = false; + + if (clearUrl) + { + TryClearWebViewUrl(); + } + } + + private bool TryReloadWebView(string action) + { + try + { + BrowserWebView.Reload(); + return true; + } + catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex)) + { + EnterFaultedState(action, ex); + return false; + } + } + + private bool TryNavigate(Uri uri, string action) + { + try + { + BrowserWebView.Url = uri; + return true; + } + catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex)) + { + EnterFaultedState(action, ex); + return false; + } + } + + private void TryClearWebViewUrl() + { + try + { + BrowserWebView.Url = null; + } + catch + { + // Best-effort cleanup only. + } + } + + private bool CanUseWebView() + { + return _runtimeAvailability.IsAvailable && !_isWebViewFaulted && _isWebViewActive; } private void ApplyRuntimeUnavailableState() { _isWebViewActive = false; - BrowserWebView.Url = null; BrowserWebView.IsVisible = false; BrowserWebView.IsHitTestVisible = false; RefreshButton.IsEnabled = false; GoButton.IsEnabled = false; AddressTextBox.IsEnabled = false; - AddressTextBox.Text = string.Empty; + AddressTextBox.Text = _lastKnownUri.ToString(); - UnavailableMessageTextBlock.Text = string.IsNullOrWhiteSpace(_runtimeAvailability.Message) - ? "WebView runtime unavailable." - : _runtimeAvailability.Message; + UnavailableMessageTextBlock.Text = _isWebViewFaulted + ? "The browser component is temporarily unavailable. Restart the app to retry." + : string.IsNullOrWhiteSpace(_runtimeAvailability.Message) + ? "WebView runtime unavailable." + : _runtimeAvailability.Message; UnavailableOverlay.IsVisible = true; } + private void EnterFaultedState(string action, Exception ex) + { + _isWebViewFaulted = true; + _isWebViewActive = false; + AppLogger.Warn( + "BrowserWidget", + $"Browser component faulted. Action={action}; ComponentId={_componentId}; PlacementId={_placementId}; RuntimeAvailability={_runtimeAvailability.IsAvailable}; RuntimeVersion={_runtimeAvailability.Version ?? string.Empty}; CurrentUrl={_lastKnownUri}", + ex); + TryClearWebViewUrl(); + ApplyRuntimeUnavailableState(); + } + private static Uri? TryNormalizeUri(string? rawText) { if (string.IsNullOrWhiteSpace(rawText)) diff --git a/LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs b/LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs new file mode 100644 index 0000000..245c15b --- /dev/null +++ b/LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs @@ -0,0 +1,226 @@ +using System; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +internal sealed class DesktopComponentFailureView : UserControl, IDesktopComponentWidget +{ + private readonly Border _rootBorder; + private readonly TextBlock _titleBlock; + private readonly TextBlock _summaryBlock; + private readonly TextBlock _statusBlock; + private readonly Button _toggleDetailsButton; + private readonly Button _copyReportButton; + private readonly Border _detailsBorder; + private readonly TextBox _reportTextBox; + private readonly string _componentId; + private readonly string? _placementId; + private readonly string _reportText; + private bool _detailsVisible; + + public DesktopComponentFailureView( + string componentName, + string componentId, + string? placementId, + int? pageIndex, + string action, + Exception exception) + { + _componentId = componentId; + _placementId = placementId; + _reportText = BuildReport(componentName, componentId, placementId, pageIndex, action, exception); + + _titleBlock = new TextBlock + { + Text = string.IsNullOrWhiteSpace(componentName) ? "组件暂时不可用" : componentName, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }; + + _summaryBlock = new TextBlock + { + Text = "该组件已临时停用,并由信息占位保留原位置。你可以展开详情或复制错误报告。", + Foreground = CreateBrush("#FFD6DEE9"), + TextWrapping = TextWrapping.Wrap + }; + + _statusBlock = new TextBlock + { + IsVisible = false, + Foreground = CreateBrush("#FF93C5FD"), + TextWrapping = TextWrapping.Wrap + }; + + _toggleDetailsButton = CreateButton("查看错误信息", OnToggleDetailsClick); + _copyReportButton = CreateButton("复制错误报告", OnCopyReportClick); + + _reportTextBox = new TextBox + { + Text = _reportText, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + MinHeight = 96, + MaxHeight = 220, + Background = CreateBrush("#CC0F172A"), + Foreground = CreateBrush("#FFE2E8F0"), + BorderThickness = new Thickness(0), + Padding = new Thickness(8) + }; + + _detailsBorder = new Border + { + IsVisible = false, + Background = CreateBrush("#660F172A"), + BorderBrush = CreateBrush("#33475569"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Child = _reportTextBox + }; + + _rootBorder = new Border + { + Background = CreateBrush("#D91E293B"), + BorderBrush = CreateBrush("#336B7280"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(18), + Padding = new Thickness(14), + ClipToBounds = true, + Child = new StackPanel + { + Spacing = 8, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + _titleBlock, + _summaryBlock, + new WrapPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + ItemSpacing = 8, + LineSpacing = 8, + Children = + { + _toggleDetailsButton, + _copyReportButton + } + }, + _statusBlock, + _detailsBorder + } + } + }; + + Content = _rootBorder; + ApplyCellSize(48); + } + + public void ApplyCellSize(double cellSize) + { + var normalized = Math.Max(1, cellSize); + _rootBorder.CornerRadius = new CornerRadius(Math.Clamp(normalized * 0.24, 12, 24)); + _rootBorder.Padding = new Thickness(Math.Clamp(normalized * 0.24, 10, 18)); + _titleBlock.FontSize = Math.Clamp(normalized * 0.36, 14, 22); + _summaryBlock.FontSize = Math.Clamp(normalized * 0.24, 11, 15); + _statusBlock.FontSize = Math.Clamp(normalized * 0.22, 10, 13); + _toggleDetailsButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14); + _copyReportButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14); + _toggleDetailsButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6); + _copyReportButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6); + _reportTextBox.FontSize = Math.Clamp(normalized * 0.2, 10, 13); + _reportTextBox.MaxHeight = Math.Clamp(normalized * 5.2, 120, 260); + } + + private static Button CreateButton(string text, EventHandler clickHandler) + { + var button = new Button + { + Content = text, + Background = CreateBrush("#80334155"), + Foreground = Brushes.White, + BorderBrush = CreateBrush("#335B6575"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + HorizontalAlignment = HorizontalAlignment.Left + }; + button.Click += clickHandler; + return button; + } + + private void OnToggleDetailsClick(object? sender, RoutedEventArgs e) + { + _detailsVisible = !_detailsVisible; + _detailsBorder.IsVisible = _detailsVisible; + _toggleDetailsButton.Content = _detailsVisible ? "隐藏错误信息" : "查看错误信息"; + UpdateStatus(null); + } + + private void OnCopyReportClick(object? sender, RoutedEventArgs e) + { + UiExceptionGuard.FireAndForgetGuarded( + CopyReportAsync, + "DesktopComponentFailureView.CopyReport", + UiExceptionGuard.BuildContext( + ("ComponentId", _componentId), + ("PlacementId", _placementId))); + } + + private async Task CopyReportAsync() + { + var topLevel = TopLevel.GetTopLevel(this); + var clipboard = topLevel?.Clipboard; + if (clipboard is null) + { + UpdateStatus("当前环境不支持复制错误报告。"); + return; + } + + await clipboard.SetTextAsync(_reportText); + UpdateStatus("错误报告已复制到剪贴板。"); + } + + private void UpdateStatus(string? message) + { + _statusBlock.Text = message ?? string.Empty; + _statusBlock.IsVisible = !string.IsNullOrWhiteSpace(message); + } + + private static string BuildReport( + string componentName, + string componentId, + string? placementId, + int? pageIndex, + string action, + Exception exception) + { + var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; + var builder = new StringBuilder(); + builder.AppendLine("LanMountainDesktop Component Failure Report"); + builder.AppendLine($"GeneratedAt: {DateTimeOffset.Now:O}"); + builder.AppendLine($"AppVersion: {version}"); + builder.AppendLine($"Action: {action}"); + builder.AppendLine($"ComponentName: {componentName}"); + builder.AppendLine($"ComponentId: {componentId}"); + builder.AppendLine($"PlacementId: {placementId ?? string.Empty}"); + builder.AppendLine($"PageIndex: {pageIndex?.ToString() ?? string.Empty}"); + builder.AppendLine($"ExceptionType: {exception.GetType().FullName}"); + builder.AppendLine($"ExceptionMessage: {exception.Message}"); + builder.AppendLine(); + builder.AppendLine(exception.ToString()); + return builder.ToString(); + } + + private static IBrush CreateBrush(string colorHex) + { + return new SolidColorBrush(Color.Parse(colorHex)); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 66d40a2..1253060 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -1522,7 +1522,7 @@ public partial class MainWindow placement.PlacementId = Guid.NewGuid().ToString("N"); } - var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId); + var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId, placement.PageIndex); if (component is null) { return null; @@ -1956,23 +1956,54 @@ public partial class MainWindow return onLeft || onRight || onTop || onBottom; } - private Control? CreateDesktopComponentControl(string componentId, string? placementId = null) + private Control? CreateDesktopComponentControl(string componentId, string? placementId = null, int? pageIndex = null) { if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor)) { return null; } - var component = runtimeDescriptor.CreateControl( - _currentDesktopCellSize, - _timeZoneService, - _weatherDataService, - _recommendationInfoService, - _calculatorDataService, - _componentSettingsService, - placementId); - component.Classes.Add(DesktopComponentClass); - return component; + return CreateDesktopComponentControl(runtimeDescriptor, _currentDesktopCellSize, placementId, pageIndex, "DesktopSurface"); + } + + private Control? CreateDesktopComponentControl( + DesktopComponentRuntimeDescriptor runtimeDescriptor, + double cellSize, + string? placementId, + int? pageIndex, + string action) + { + try + { + var component = runtimeDescriptor.CreateControl( + cellSize, + _timeZoneService, + _weatherDataService, + _recommendationInfoService, + _calculatorDataService, + _componentSettingsService, + placementId); + component.Classes.Add(DesktopComponentClass); + return component; + } + catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex)) + { + AppLogger.Warn( + "ComponentRuntime", + $"Action={action}; ComponentId={runtimeDescriptor.Definition.Id}; PlacementId={placementId ?? string.Empty}; PageIndex={pageIndex?.ToString() ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false", + ex); + + var failureView = new DesktopComponentFailureView( + runtimeDescriptor.Definition.DisplayName, + runtimeDescriptor.Definition.Id, + placementId, + pageIndex, + action, + ex); + failureView.ApplyCellSize(cellSize); + failureView.Classes.Add(DesktopComponentClass); + return failureView; + } } private void CollapseComponentLibraryPanel() @@ -3113,13 +3144,16 @@ public partial class MainWindow var previewHeight = previewSpan.HeightCells * previewCellSize; var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110); - var previewControl = descriptor.CreateControl( + var previewControl = CreateDesktopComponentControl( + descriptor, renderCellSize, - _timeZoneService, - _weatherDataService, - _recommendationInfoService, - _calculatorDataService, - _componentSettingsService); + placementId: null, + pageIndex: null, + action: "ComponentLibraryPreview"); + if (previewControl is null) + { + continue; + } // Component library previews must stay non-interactive so drag gesture is reliable. previewControl.IsHitTestVisible = false; previewControl.Focusable = false; diff --git a/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs b/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs index 9720f50..02c5ba4 100644 --- a/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs +++ b/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs @@ -1,59 +1,58 @@ -using System; -using Avalonia.Interactivity; +using System.Threading.Tasks; using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; public partial class MainWindow { - private readonly DispatcherTimer _singleInstanceNoticeTimer = new() - { - Interval = TimeSpan.FromSeconds(6) - }; + private bool _isSingleInstancePromptVisible; internal void ShowSingleInstanceNotice() { + void ShowPrompt() + { + UiExceptionGuard.FireAndForgetGuarded( + ShowSingleInstanceNoticeCoreAsync, + "MainWindow.ShowSingleInstanceNotice"); + } + if (Dispatcher.UIThread.CheckAccess()) { - ShowSingleInstanceNoticeCore(); + ShowPrompt(); return; } - Dispatcher.UIThread.Post(ShowSingleInstanceNoticeCore, DispatcherPriority.Send); + Dispatcher.UIThread.Post(ShowPrompt, DispatcherPriority.Send); } - private void ShowSingleInstanceNoticeCore() + private async Task ShowSingleInstanceNoticeCoreAsync() { - SingleInstanceNoticeTitleTextBlock.Text = L( - "single_instance.notice.title", - "App already open"); - SingleInstanceNoticeDescriptionTextBlock.Text = L( - "single_instance.notice.description", - "LanMountainDesktop is already running. Switched back to the active desktop."); - SingleInstanceNoticeButtonTextBlock.Text = L( - "single_instance.notice.button", - "Got it"); - SingleInstanceNoticeDock.IsVisible = true; + if (_isSingleInstancePromptVisible) + { + return; + } - _singleInstanceNoticeTimer.Stop(); - _singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick; - _singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick; - _singleInstanceNoticeTimer.Start(); - } + _isSingleInstancePromptVisible = true; - private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e) - { - HideSingleInstanceNotice(); - } + try + { + var dialog = new ContentDialog + { + Title = L("single_instance.notice.title", "应用已经运行"), + Content = L( + "single_instance.notice.description", + "应用已经运行,无需多次点击打开。"), + PrimaryButtonText = L("single_instance.notice.button", "确定"), + DefaultButton = ContentDialogButton.Primary + }; - private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e) - { - HideSingleInstanceNotice(); - } - - private void HideSingleInstanceNotice() - { - _singleInstanceNoticeTimer.Stop(); - SingleInstanceNoticeDock.IsVisible = false; + await dialog.ShowAsync(this); + } + finally + { + _isSingleInstancePromptVisible = false; + } } } diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 23b93b9..efb65bc 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -471,51 +471,6 @@ - - - - - - - - - - - - - - { - if (_hasLoadedOnce) - { - return; - } - - _hasLoadedOnce = true; - await RefreshAsync(); - }; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; } public void RefreshInstalledSnapshot() @@ -134,18 +131,47 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable public void Dispose() { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _lifetimeCts.Cancel(); + foreach (var bitmap in _iconBitmaps.Values) { bitmap?.Dispose(); } _iconBitmaps.Clear(); + _lifetimeCts.Dispose(); _iconService.Dispose(); _readmeService.Dispose(); _installService.Dispose(); _indexService.Dispose(); } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttachedToVisualTree = true; + if (_hasLoadedOnce) + { + return; + } + + _hasLoadedOnce = true; + UiExceptionGuard.FireAndForgetGuarded( + RefreshAsync, + "PluginMarket.InitialLoad", + BuildMarketContext()); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttachedToVisualTree = false; + } + private Control BuildLayout() { var root = new Grid @@ -197,14 +223,23 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable return root; } - private async void OnRefreshClick(object? sender, RoutedEventArgs e) + private void OnRefreshClick(object? sender, RoutedEventArgs e) { - await RefreshAsync(); + UiExceptionGuard.FireAndForgetGuarded( + RefreshAsync, + "PluginMarket.Refresh", + BuildMarketContext(), + ex => HandleTopLevelUiActionExceptionAsync( + ex, + F( + "market.status.load_failed_format", + "Failed to load the plugin market: {0}", + DescribeException(ex)))); } private async Task RefreshAsync() { - if (_isRefreshing) + if (_isRefreshing || _isDisposed || _lifetimeCts.IsCancellationRequested) { return; } @@ -217,11 +252,19 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable { RefreshInstalledSnapshot(); - var result = await _indexService.LoadAsync(); + var result = await _indexService.LoadAsync(_lifetimeCts.Token); + if (!CanUpdateUi()) + { + return; + } + if (!result.Success || result.Document is null) { _document = null; _selectedPlugin = null; + AppLogger.Warn( + "PluginMarket", + $"Refresh failed. Source=None; Warning={result.WarningMessage ?? string.Empty}; Error={result.ErrorMessage ?? string.Empty}; Context={BuildMarketContext()}"); SetStatus( F( "market.status.load_failed_format", @@ -235,6 +278,9 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _document = result.Document; _marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl; _selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins); + AppLogger.Info( + "PluginMarket", + $"Refresh completed. Source={result.Source}; PluginCount={result.Document.Plugins.Count}; SourceLocation={result.SourceLocation ?? string.Empty}; Warning={result.WarningMessage ?? string.Empty}; Context={BuildMarketContext()}"); var statusMessage = result.Source == AirAppMarketLoadSource.Cache ? F( @@ -251,15 +297,43 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable RebuildSurface(); await EnsureReadmeLoadedAsync(_selectedPlugin); } + catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) + { + AppLogger.Info("PluginMarket", $"Refresh canceled because the view is being disposed. Context={BuildMarketContext()}"); + } + catch (Exception ex) + { + AppLogger.Warn( + "PluginMarket", + $"Refresh threw unexpectedly. ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext()}", + ex); + if (CanUpdateUi()) + { + SetStatus( + F( + "market.status.load_failed_format", + "Failed to load the plugin market: {0}", + DescribeException(ex)), + ErrorBrush); + _document = null; + _selectedPlugin = null; + RebuildSurface(); + } + } finally { _isRefreshing = false; - _refreshButton.IsEnabled = true; + _refreshButton.IsEnabled = !_isDisposed; } } private void RebuildSurface() { + if (_isDisposed) + { + return; + } + var filteredPlugins = GetFilteredPlugins(); _selectedPlugin = filteredPlugins.Count > 0 ? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins) @@ -396,7 +470,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable HorizontalContentAlignment = HorizontalAlignment.Stretch, Content = selectGrid }; - selectButton.Click += async (_, _) => await SelectPluginAsync(plugin); + selectButton.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded( + () => SelectPluginAsync(plugin), + "PluginMarket.SelectPlugin", + BuildMarketContext(plugin)); var rightPanel = new StackPanel { @@ -623,13 +700,22 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable HorizontalAlignment = HorizontalAlignment.Right }; - button.Click += async (_, _) => - { - _selectedPlugin = plugin; - RebuildSurface(); - await EnsureReadmeLoadedAsync(plugin); - await InstallSelectedPluginAsync(plugin); - }; + button.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded( + async () => + { + _selectedPlugin = plugin; + RebuildSurface(); + await EnsureReadmeLoadedAsync(plugin); + await InstallSelectedPluginAsync(plugin); + }, + "PluginMarket.InstallPlugin", + BuildMarketContext(plugin), + ex => HandleTopLevelUiActionExceptionAsync( + ex, + F( + "market.status.install_failed_format", + "Failed to install plugin: {0}", + DescribeException(ex)))); return button; } @@ -643,7 +729,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin) { - if (_isInstalling) + if (_isInstalling || _isDisposed || _lifetimeCts.IsCancellationRequested) { return; } @@ -658,7 +744,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable try { - var result = await _installService.InstallAsync(plugin); + var result = await _installService.InstallAsync(plugin, _lifetimeCts.Token); + if (!CanUpdateUi()) + { + return; + } + if (!result.Success || result.Manifest is null) { SetStatus( @@ -679,6 +770,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable SuccessBrush); RebuildSurface(); } + catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) + { + AppLogger.Info( + "PluginMarket", + $"Install canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); + } finally { _isInstalling = false; @@ -690,6 +787,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin) { if (plugin is null || + _isDisposed || _readmeContents.ContainsKey(plugin.Id) || string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase)) { @@ -702,19 +800,30 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable try { - var readme = await _readmeService.LoadAsync(plugin); + var readme = await _readmeService.LoadAsync(plugin, _lifetimeCts.Token); _readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme) ? T("market.detail.readme_empty", "README is empty.") : readme.Trim(); } + catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) + { + AppLogger.Info( + "PluginMarket", + $"README load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); + } catch (Exception ex) { + AppLogger.Warn( + "PluginMarket", + $"README load failed. PluginId={plugin.Id}; ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext(plugin)}", + ex); _readmeErrors[plugin.Id] = ex.Message; } finally { _loadingReadmePluginId = null; - if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase)) + if (CanUpdateUi() && + string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase)) { BuildDetailPanel(); } @@ -724,6 +833,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private async Task EnsureIconLoadedAsync(AirAppMarketPluginEntry? plugin) { if (plugin is null || + _isDisposed || _iconBitmaps.ContainsKey(plugin.Id) || !_loadingIconPluginIds.Add(plugin.Id)) { @@ -732,7 +842,13 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable try { - _iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin); + _iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin, _lifetimeCts.Token); + } + catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) + { + AppLogger.Info( + "PluginMarket", + $"Icon load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}"); } catch { @@ -741,8 +857,61 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable finally { _loadingIconPluginIds.Remove(plugin.Id); + if (CanUpdateUi()) + { + RebuildSurface(); + } + } + } + + private Task HandleTopLevelUiActionExceptionAsync(Exception ex, string fallbackStatus) + { + if (CanUpdateUi()) + { + SetStatus(fallbackStatus, ErrorBrush); RebuildSurface(); } + + return Task.CompletedTask; + } + + private bool CanUpdateUi() + { + return !_isDisposed && _isAttachedToVisualTree && !_lifetimeCts.IsCancellationRequested; + } + + private string BuildMarketContext(AirAppMarketPluginEntry? plugin = null) + { + return UiExceptionGuard.BuildContext( + ("SelectedPluginId", _selectedPlugin?.Id), + ("PluginId", plugin?.Id), + ("Source", _marketSourceDisplay), + ("IsRefreshing", _isRefreshing), + ("IsInstalling", _isInstalling), + ("IsDisposed", _isDisposed)); + } + + private static string ClassifyException(Exception ex) + { + return ex switch + { + OperationCanceledException => "Canceled", + TimeoutException => "Timeout", + HttpRequestException => "Network", + IOException => "IO", + _ => "Unexpected" + }; + } + + private static string DescribeException(Exception ex) + { + return ex switch + { + OperationCanceledException => "The request timed out or was canceled.", + TimeoutException => "The request timed out.", + HttpRequestException => ex.Message, + _ => ex.Message + }; } private string GetReadmeContent(AirAppMarketPluginEntry plugin) diff --git a/LanMountainDesktop/plugins/PluginMarketIndexService.cs b/LanMountainDesktop/plugins/PluginMarketIndexService.cs index 0582b10..a7e5ffb 100644 --- a/LanMountainDesktop/plugins/PluginMarketIndexService.cs +++ b/LanMountainDesktop/plugins/PluginMarketIndexService.cs @@ -43,10 +43,14 @@ internal sealed class AirAppMarketIndexService : IDisposable null, null); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (OperationCanceledException ex) + { + networkError = ex; + } catch (Exception ex) { networkError = ex; @@ -71,10 +75,14 @@ internal sealed class AirAppMarketIndexService : IDisposable null, null); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (OperationCanceledException ex) + { + networkError = ex; + } catch (Exception ex) { networkError = ex; diff --git a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs index 71684fe..cf0a03e 100644 --- a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs +++ b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs @@ -130,7 +130,25 @@ public partial class PluginSettingsPage : UserControl }; } - private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + UiExceptionGuard.FireAndForgetGuarded( + OnInstallPluginPackageAsync, + "PluginSettings.InstallPackage", + context: "Page=PluginSettings", + onHandledException: ex => + { + SetPackageImportStatus( + F( + "settings.plugins.install_failed_format", + "Failed to install plugin package: {0}", + ex.Message), + isError: true); + return Task.CompletedTask; + }); + } + + private async Task OnInstallPluginPackageAsync() { var runtime = (Application.Current as App)?.PluginRuntimeService; if (runtime is null)