我认为很稳定了,后面就要开始弄插件不稳定了
This commit is contained in:
lincube
2026-03-12 12:25:22 +08:00
parent 6952cb2c3e
commit 4679ee006f
20 changed files with 1197 additions and 205 deletions

View File

@@ -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 (_, _) =>
{
_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)

View File

@@ -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;

View File

@@ -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)