mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.5.20
我认为很稳定了,后面就要开始弄插件不稳定了
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||
|
||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
@@ -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<SamplePluginSettings>("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<SamplePluginSettings>(
|
||||
"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;
|
||||
}
|
||||
}
|
||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
@@ -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<OutOfMemoryException>(() =>
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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": "确定"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -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<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||
return NormalizeDocument(snapshot);
|
||||
return new ComponentSettingsLoadDetails(
|
||||
snapshot ?? new ComponentSettingsDocumentSnapshot(),
|
||||
format,
|
||||
format == ComponentSettingsDocumentFormat.PascalCaseDocument);
|
||||
}
|
||||
|
||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return new ComponentSettingsDocumentSnapshot
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
@@ -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<Task> action,
|
||||
string actionName,
|
||||
string? context = null,
|
||||
Func<Exception, Task>? onHandledException = null)
|
||||
{
|
||||
_ = RunGuardedUiActionAsync(action, actionName, context, onHandledException);
|
||||
}
|
||||
|
||||
public static async Task RunGuardedUiActionAsync(
|
||||
Func<Task> action,
|
||||
string actionName,
|
||||
string? context = null,
|
||||
Func<Exception, Task>? 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 ?? "<null>"}"));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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))
|
||||
|
||||
@@ -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<RoutedEventArgs> 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));
|
||||
}
|
||||
}
|
||||
@@ -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,15 +1956,27 @@ 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;
|
||||
}
|
||||
|
||||
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(
|
||||
_currentDesktopCellSize,
|
||||
cellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
@@ -1974,6 +1986,25 @@ public partial class MainWindow
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
if (_isSingleInstancePromptVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSingleInstancePromptVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = L("single_instance.notice.title", "应用已经运行"),
|
||||
Content = 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;
|
||||
"应用已经运行,无需多次点击打开。"),
|
||||
PrimaryButtonText = L("single_instance.notice.button", "确定"),
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
_singleInstanceNoticeTimer.Stop();
|
||||
_singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick;
|
||||
_singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick;
|
||||
_singleInstanceNoticeTimer.Start();
|
||||
await dialog.ShowAsync(this);
|
||||
}
|
||||
|
||||
private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e)
|
||||
finally
|
||||
{
|
||||
HideSingleInstanceNotice();
|
||||
}
|
||||
|
||||
private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
HideSingleInstanceNotice();
|
||||
}
|
||||
|
||||
private void HideSingleInstanceNotice()
|
||||
{
|
||||
_singleInstanceNoticeTimer.Stop();
|
||||
SingleInstanceNoticeDock.IsVisible = false;
|
||||
_isSingleInstancePromptVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,51 +471,6 @@
|
||||
|
||||
<StackPanel Grid.Row="1"
|
||||
Spacing="12">
|
||||
<Border x:Name="SingleInstanceNoticeDock"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
CornerRadius="18"
|
||||
Padding="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="Alert"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="SingleInstanceNoticeTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="App already open" />
|
||||
<TextBlock x:Name="SingleInstanceNoticeDescriptionTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="LanMountainDesktop is already running. Switched back to the active desktop." />
|
||||
</StackPanel>
|
||||
<Button x:Name="SingleInstanceNoticeButton"
|
||||
Grid.Column="2"
|
||||
Padding="14,8"
|
||||
Click="OnSingleInstanceNoticeButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="Checkmark"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="SingleInstanceNoticeButtonTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Got it" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -37,6 +39,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
private readonly AirAppMarketReadmeService _readmeService;
|
||||
private readonly AirAppMarketIconService _iconService;
|
||||
private readonly Version? _hostVersion;
|
||||
private readonly CancellationTokenSource _lifetimeCts = new();
|
||||
|
||||
private readonly TextBox _searchTextBox;
|
||||
private readonly Button _refreshButton;
|
||||
@@ -57,6 +60,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
private bool _isRefreshing;
|
||||
private bool _isInstalling;
|
||||
private bool _hasLoadedOnce;
|
||||
private bool _isDisposed;
|
||||
private bool _isAttachedToVisualTree;
|
||||
|
||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||
{
|
||||
@@ -106,16 +111,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
_detailBorder = CreatePanelShell(18);
|
||||
|
||||
Content = BuildLayout();
|
||||
AttachedToVisualTree += async (_, _) =>
|
||||
{
|
||||
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 (_, _) =>
|
||||
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,9 +857,62 @@ 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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user