Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/UniGetUI.Avalonia/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using UniGetUI.Avalonia.Assets.Styles;
using UniGetUI.Avalonia.Infrastructure;
using UniGetUI.Avalonia.Views;
using UniGetUI.Avalonia.Views.Controls;
using UniGetUI.Avalonia.Views.DialogPages;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
Expand All @@ -29,6 +30,7 @@ public override void Initialize()
AvaloniaXamlLoader.Load(this);

ButtonActivationGuard.Install();
SmoothScrollManager.Install();

// Windows 11 Mica look is opt-in per environment: only merge the translucent
// surface overrides when Mica is actually usable (Win11 + transparency on).
Expand Down
161 changes: 101 additions & 60 deletions src/UniGetUI.Avalonia/Models/PackageCollections.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
Timeout = TimeSpan.FromSeconds(8),
};
private static readonly SemaphoreSlim _iconLoadSemaphore = new(8, 8);
private static readonly object _inflightIconLoadsLock = new();
private static readonly Dictionary<long, Task<Bitmap?>> _inflightIconLoads = new();

// Cap decoded icon size; the list shows icons at ≤64px (128 covers 2x DPI).
private const int MaxIconSide = 128;
private const int MaxIconDownloadBytes = 2 * 1024 * 1024;

// Bounded LRU by package hash. Evicted entries aren't disposed: a visible row may still
// reference the bitmap, so dropping it here only makes it GC-eligible.
Expand Down Expand Up @@ -163,18 +166,26 @@
Package.PropertyChanged += Package_PropertyChanged;
UpdateDisplayState();

// Icons load lazily per visible row (see EnsureIconLoaded), not eagerly for every result.
// Icons are normally preloaded after the result set completes. The visible-row hook remains
// as a fallback while results are still arriving.
MaybeStartInstallerHostCheck();
}

private int _iconLoadStarted;
private readonly object _iconLoadLock = new();
private Task? _iconLoadTask;

/// <summary>Loads this row's icon at most once; called when the row becomes visible.</summary>
/// <summary>Loads this row's icon at most once; also called when the row becomes visible.</summary>
public void EnsureIconLoaded()
{
if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return;
if (Interlocked.Exchange(ref _iconLoadStarted, 1) != 0) return;
_ = LoadIconAsync();
_ = EnsureIconLoadedAsync();
}

/// <summary>Loads this row's icon at most once and returns the shared load operation.</summary>
public Task EnsureIconLoadedAsync()
{
if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return Task.CompletedTask;
lock (_iconLoadLock)
return _iconLoadTask ??= LoadIconAsync();
}

/// <summary>
Expand All @@ -183,7 +194,7 @@
/// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the
/// download to a different domain than the user originally trusted.
/// </summary>
private void MaybeStartInstallerHostCheck()

Check warning on line 197 in src/UniGetUI.Avalonia/Models/PackageCollections.cs

View workflow job for this annotation

GitHub Actions / Linux (Avalonia)

Member 'MaybeStartInstallerHostCheck' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 197 in src/UniGetUI.Avalonia/Models/PackageCollections.cs

View workflow job for this annotation

GitHub Actions / Linux (Avalonia)

Member 'MaybeStartInstallerHostCheck' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 197 in src/UniGetUI.Avalonia/Models/PackageCollections.cs

View workflow job for this annotation

GitHub Actions / Linux (NativeAOT)

Member 'MaybeStartInstallerHostCheck' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
{
#if WINDOWS
if (!Package.IsUpgradable) return;
Expand Down Expand Up @@ -263,49 +274,97 @@

try
{
await _iconLoadSemaphore.WaitAsync(token).ConfigureAwait(false);
Bitmap bitmap;
try
Bitmap? bitmap = await GetSharedIconLoad(hash, Package).WaitAsync(token).ConfigureAwait(false);
if (bitmap is null) return;

if (token.IsCancellationRequested) return;
await Dispatcher.UIThread.InvokeAsync(() =>
{
var uri = await Task.Run(Package.GetIconUrlIfAny, token).ConfigureAwait(false);
if (uri is null) { CacheIcon(hash, null); return; }
if (!token.IsCancellationRequested) IconBitmap = bitmap;
});
}
catch (OperationCanceledException) { /* row discarded before its icon finished loading */ }
catch { CacheIcon(hash, null); }
}

Bitmap? decoded;
if (uri.IsFile)
private static Task<Bitmap?> GetSharedIconLoad(long hash, IPackage package)
{
lock (_inflightIconLoadsLock)
{
if (TryGetCachedIcon(hash, out Bitmap? cached))
return Task.FromResult(cached);
if (_inflightIconLoads.TryGetValue(hash, out Task<Bitmap?>? existing))
return existing;

Task<Bitmap?> task = LoadAndCacheIconAsync(hash, package);
_inflightIconLoads[hash] = task;
_ = RemoveInflightIconLoadAsync(hash, task);
return task;
}
}

private static async Task RemoveInflightIconLoadAsync(long hash, Task<Bitmap?> task)
{
try { await task.ConfigureAwait(false); }
finally
{
lock (_inflightIconLoadsLock)
{
if (_inflightIconLoads.TryGetValue(hash, out Task<Bitmap?>? current)
&& ReferenceEquals(current, task))
_inflightIconLoads.Remove(hash);
}
}
}

private static async Task<Bitmap?> LoadAndCacheIconAsync(long hash, IPackage package)
{
await _iconLoadSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var uri = await Task.Run(package.GetIconUrlIfAny).ConfigureAwait(false);
if (uri is null) { CacheIcon(hash, null); return null; }

Bitmap? decoded;
if (uri.IsFile)
{
if (!IsSkiaDecodableExtension(uri.LocalPath))
{
if (!IsSkiaDecodableExtension(uri.LocalPath))
{
// Avalonia's Bitmap (Skia) can't decode SVG/AVIF/ICO/TIFF — the
// icon cache may produce those. Reject upfront so we don't throw.
CacheIcon(hash, null);
return;
}
decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath), token).ConfigureAwait(false);
CacheIcon(hash, null);
return null;
}
else if (uri.Scheme is "http" or "https")
decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath)).ConfigureAwait(false);
}
else if (uri.Scheme is "http" or "https")
{
using var response = await _iconHttpClient.GetAsync(
uri,
HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > MaxIconDownloadBytes)
{
var bytes = await _iconHttpClient.GetByteArrayAsync(uri, token).ConfigureAwait(false);
decoded = TryDecodeIcon(bytes, uri.Host);
CacheIcon(hash, null);
return null;
}
else { CacheIcon(hash, null); return; }

if (decoded is null) { CacheIcon(hash, null); return; }
bitmap = decoded;
CacheIcon(hash, bitmap);
}
finally
{
_iconLoadSemaphore.Release();
await response.Content.LoadIntoBufferAsync(MaxIconDownloadBytes).ConfigureAwait(false);
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
decoded = TryDecodeIcon(bytes, uri.Host);
}
else { CacheIcon(hash, null); return null; }

if (token.IsCancellationRequested) return;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!token.IsCancellationRequested) IconBitmap = bitmap;
});
CacheIcon(hash, decoded);
return decoded;
}
catch
{
CacheIcon(hash, null);
return null;
}
finally
{
_iconLoadSemaphore.Release();
}
catch (OperationCanceledException) { /* row discarded before its icon finished loading */ }
catch { CacheIcon(hash, null); }
}

// Icons come from a shared on-disk cache that can hold empty or partial entries after an
Expand All @@ -326,28 +385,10 @@
catch (Exception ex) { Logger.Debug($"Discarding undecodable icon '{source}': {ex.Message}"); return null; }
}

// Downscales oversized icons to MaxIconSide; small icons pass through (never upscaled).
// Decode directly at the display-cache width. This avoids allocating a full-size bitmap first,
// which is important for untrusted or unusually large package artwork.
private static Bitmap DecodeDownscaled(Stream stream)
{
var bitmap = new Bitmap(stream);
PixelSize size = bitmap.PixelSize;
if (size.Width <= MaxIconSide && size.Height <= MaxIconSide)
return bitmap;

double scale = (double)MaxIconSide / Math.Max(size.Width, size.Height);
var target = new PixelSize(
Math.Max(1, (int)Math.Round(size.Width * scale)),
Math.Max(1, (int)Math.Round(size.Height * scale)));

try
{
return bitmap.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality);
}
finally
{
bitmap.Dispose();
}
}
=> Bitmap.DecodeToWidth(stream, MaxIconSide, BitmapInterpolationMode.HighQuality);

private static bool IsSkiaDecodableExtension(string path)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public bool IsExpanded

public partial class PackagesPageViewModel : ViewModelBase
{
private const int MaximumPreloadedIcons = 512;
// Live width of the filter pane. Code-behind keeps this in sync with the GridSplitter
// so the toolbar's main button (bound to FilterPaneColumnWidth) tracks resizes.
private double _trackedFilterPaneWidth = 220.0;
Expand Down Expand Up @@ -204,6 +205,7 @@ partial void OnIsFilterPaneOpenChanged(bool value)
public string QueryBackup { get; set; } = "";

private readonly ObservableCollection<PackageWrapper> _wrappedPackages = new();
private CancellationTokenSource? _iconPreloadCts;
protected List<IPackageManager> UsedManagers = [];
protected ConcurrentDictionary<IPackageManager, List<IManagerSource>> UsedSourcesForManager = new();
protected ConcurrentDictionary<IPackageManager, SourceTreeNode> RootNodeForManager = new();
Expand Down Expand Up @@ -473,9 +475,37 @@ private void Loader_FinishedLoading(object? sender, EventArgs e)
_lastLoadTime = DateTime.Now;
ReloadButtonTooltip = CoreTools.Translate("Last checked: {0}", _lastLoadTime.ToString(CultureInfo.CurrentCulture));
FilterPackages();
_iconPreloadCts?.Cancel();
_iconPreloadCts?.Dispose();
_iconPreloadCts = new CancellationTokenSource();
_ = PreloadPackageIconsAsync(
FilteredPackages.Take(MaximumPreloadedIcons).ToArray(),
_iconPreloadCts.Token);
PackagesLoaded?.Invoke(ReloadReason.External);
}

private static async Task PreloadPackageIconsAsync(
PackageWrapper[] wrappers,
CancellationToken cancellationToken)
{
try
{
// Leave half the global icon-loader slots free for rows that become visible immediately.
const int batchSize = 4;
for (int start = 0; start < wrappers.Length; start += batchSize)
{
cancellationToken.ThrowIfCancellationRequested();
int count = Math.Min(batchSize, wrappers.Length - start);
var tasks = new Task[count];
for (int index = 0; index < count; index++)
tasks[index] = wrappers[start + index].EnsureIconLoadedAsync();

await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
}

private void Loader_StartedLoading(object? sender, EventArgs e)
{
if (!Dispatcher.UIThread.CheckAccess())
Expand All @@ -484,6 +514,7 @@ private void Loader_StartedLoading(object? sender, EventArgs e)
return;
}
IsLoading = true;
_iconPreloadCts?.Cancel();
UpdateSubtitle();
}

Expand Down
78 changes: 0 additions & 78 deletions src/UniGetUI.Avalonia/Views/Controls/DataGridWheelAnimator.cs

This file was deleted.

Loading
Loading