Skip to content

Commit 73dbe37

Browse files
authored
Avalonia: Feature parity with WinUI (#4669)
1 parent 8ab9538 commit 73dbe37

28 files changed

Lines changed: 1124 additions & 476 deletions

src/UniGetUI.Avalonia/App.axaml.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Avalonia.Markup.Xaml.Styling;
66
using Avalonia.Platform;
77
using Avalonia.Styling;
8+
using Avalonia.Threading;
89
using UniGetUI.Avalonia.Infrastructure;
910
using UniGetUI.Avalonia.Views;
1011
using UniGetUI.Avalonia.Views.DialogPages;
@@ -35,6 +36,21 @@ public override void Initialize()
3536

3637
public override void OnFrameworkInitializationCompleted()
3738
{
39+
if (OperatingSystem.IsWindows())
40+
{
41+
// Safety net for NativeWebView (WebView2) initialization failures thrown
42+
// asynchronously on the dispatcher. Without this the app crashes; with it
43+
// the Help page shows a fallback "Open in browser" button.
44+
Dispatcher.UIThread.UnhandledException += (_, e) =>
45+
{
46+
if (e.Exception is InvalidOperationException { Message: var msg }
47+
&& msg.Contains("child window for native control host"))
48+
{
49+
e.Handled = true;
50+
}
51+
};
52+
}
53+
3854
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
3955
{
4056
if (OperatingSystem.IsMacOS())

src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,43 @@ public static void Add(AbstractOperation op)
9393
};
9494
}
9595

96+
public static void RetryFailed()
97+
{
98+
var failed = OperationViewModels
99+
.Where(vm => vm.Operation.Status is OperationStatus.Failed)
100+
.ToList();
101+
foreach (var vm in failed)
102+
vm.Operation.Retry(AbstractOperation.RetryMode.Retry);
103+
}
104+
105+
public static void ClearSuccessful()
106+
{
107+
var succeeded = OperationViewModels
108+
.Where(vm => vm.Operation.Status is OperationStatus.Succeeded)
109+
.ToList();
110+
foreach (var vm in succeeded)
111+
Remove(vm);
112+
}
113+
114+
public static void ClearFinished()
115+
{
116+
var finished = OperationViewModels
117+
.Where(vm => vm.Operation.Status
118+
is OperationStatus.Succeeded or OperationStatus.Failed or OperationStatus.Canceled)
119+
.ToList();
120+
foreach (var vm in finished)
121+
Remove(vm);
122+
}
123+
124+
public static void CancelAll()
125+
{
126+
var active = OperationViewModels
127+
.Where(vm => vm.Operation.Status is OperationStatus.Running or OperationStatus.InQueue)
128+
.ToList();
129+
foreach (var vm in active)
130+
vm.Operation.Cancel();
131+
}
132+
96133
/// <summary>Remove a view-model (and its backing operation) from the panel. Called by the Close button.</summary>
97134
public static void Remove(OperationViewModel vm)
98135
{

src/UniGetUI.Avalonia/Models/PackageCollections.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
using System.Collections.Concurrent;
12
using System.Collections.ObjectModel;
23
using System.ComponentModel;
4+
using System.Net.Http;
35
using Avalonia.Collections;
6+
using Avalonia.Media.Imaging;
7+
using Avalonia.Threading;
48
using UniGetUI.Avalonia.ViewModels.Pages;
9+
using UniGetUI.Core.SettingsEngine;
10+
using UniGetUI.Core.Tools;
511
using UniGetUI.Interface.Enums;
612
using UniGetUI.PackageEngine.Interfaces;
713

@@ -13,6 +19,12 @@ namespace UniGetUI.PackageEngine.PackageClasses;
1319
/// </summary>
1420
public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable
1521
{
22+
private static readonly HttpClient _iconHttpClient = new(CoreTools.GenericHttpClientParameters)
23+
{
24+
Timeout = TimeSpan.FromSeconds(8),
25+
};
26+
private static readonly ConcurrentDictionary<long, Bitmap?> _iconCache = new();
27+
1628
public IPackage Package { get; }
1729
public PackageWrapper Self => this;
1830
public int Index { get; set; }
@@ -21,6 +33,19 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable
2133

2234
private readonly PackagesPageViewModel _page;
2335

36+
private Bitmap? _iconBitmap;
37+
public Bitmap? IconBitmap
38+
{
39+
get => _iconBitmap;
40+
private set
41+
{
42+
_iconBitmap = value;
43+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconBitmap)));
44+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCustomIcon)));
45+
}
46+
}
47+
public bool HasCustomIcon => _iconBitmap is not null;
48+
2449
public bool IsChecked
2550
{
2651
get => Package.IsChecked;
@@ -35,6 +60,8 @@ public bool IsChecked
3560
public string VersionComboString { get; }
3661
public string ListedNameTooltip { get; private set; } = "";
3762
public float ListedOpacity { get; private set; } = 1.0f;
63+
public string TagIconPath { get; private set; } = "";
64+
public bool TagIconVisible { get; private set; }
3865

3966
public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId);
4067

@@ -63,6 +90,43 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page)
6390

6491
Package.PropertyChanged += Package_PropertyChanged;
6592
UpdateDisplayState();
93+
94+
if (!Settings.Get(Settings.K.DisableIconsOnPackageLists))
95+
_ = LoadIconAsync();
96+
}
97+
98+
private async Task LoadIconAsync()
99+
{
100+
long hash = Package.GetHash();
101+
if (_iconCache.TryGetValue(hash, out Bitmap? cached))
102+
{
103+
if (cached is not null)
104+
IconBitmap = cached;
105+
return;
106+
}
107+
108+
try
109+
{
110+
var uri = Package.GetIconUrlIfAny();
111+
if (uri is null) { _iconCache[hash] = null; return; }
112+
113+
Bitmap bitmap;
114+
if (uri.IsFile)
115+
{
116+
bitmap = new Bitmap(uri.LocalPath);
117+
}
118+
else if (uri.Scheme is "http" or "https")
119+
{
120+
var bytes = await _iconHttpClient.GetByteArrayAsync(uri);
121+
using var ms = new MemoryStream(bytes);
122+
bitmap = new Bitmap(ms);
123+
}
124+
else { _iconCache[hash] = null; return; }
125+
126+
_iconCache[hash] = bitmap;
127+
await Dispatcher.UIThread.InvokeAsync(() => IconBitmap = bitmap);
128+
}
129+
catch { _iconCache[hash] = null; }
66130
}
67131

68132
private void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -72,6 +136,8 @@ private void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e)
72136
UpdateDisplayState();
73137
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedOpacity)));
74138
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedNameTooltip)));
139+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagIconPath)));
140+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagIconVisible)));
75141
}
76142
else if (e.PropertyName == nameof(Package.IsChecked))
77143
{
@@ -91,6 +157,21 @@ private void UpdateDisplayState()
91157
_ => 1.0f,
92158
};
93159
ListedNameTooltip = Package.Name;
160+
161+
string tagName = Package.Tag switch
162+
{
163+
PackageTag.AlreadyInstalled => "installed_filled",
164+
PackageTag.IsUpgradable => "upgradable_filled",
165+
PackageTag.Pinned => "pin_filled",
166+
PackageTag.OnQueue => "sandclock",
167+
PackageTag.BeingProcessed => "loading_filled",
168+
PackageTag.Failed => "warning_filled",
169+
_ => "",
170+
};
171+
TagIconVisible = tagName.Length > 0;
172+
TagIconPath = TagIconVisible
173+
? $"avares://UniGetUI.Avalonia/Assets/Symbols/{tagName}.svg"
174+
: "";
94175
}
95176

96177
public void Dispose()

src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<AssemblyName>UniGetUI.Avalonia</AssemblyName>
2121
<ApplicationIcon>..\UniGetUI\icon.ico</ApplicationIcon>
2222
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
23+
<ApplicationManifest Condition="$([MSBuild]::IsOSPlatform('Windows'))">app.manifest</ApplicationManifest>
2324
</PropertyGroup>
2425

2526
<PropertyGroup Condition="'$(EnableAvaloniaDiagnostics)' == 'true'">

src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ public InstallOptionsViewModel(IPackage package, OperationType operation, Instal
305305
}
306306

307307
// ── Commands ──────────────────────────────────────────────────────────────
308+
309+
/// <summary>Captures the current UI state into the options object without closing.</summary>
310+
public void ApplyChanges() => ApplyToOptions();
311+
308312
[RelayCommand]
309313
private void Save()
310314
{
Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
1+
using System.Collections.ObjectModel;
2+
using Avalonia.Media;
3+
using Avalonia.Threading;
14
using CommunityToolkit.Mvvm.ComponentModel;
5+
using UniGetUI.Avalonia.ViewModels.Pages.LogPages;
26
using UniGetUI.PackageOperations;
37

48
namespace UniGetUI.Avalonia.ViewModels.DialogPages;
59

610
public partial class OperationOutputViewModel : ObservableObject
711
{
812
[ObservableProperty] private string _title = "";
9-
[ObservableProperty] private string _outputText = "";
13+
public ObservableCollection<LogLineItem> OutputLines { get; } = new();
14+
15+
private static readonly IBrush _errorBrush = new SolidColorBrush(Color.Parse("#FF6B6B"));
16+
private static readonly IBrush _debugBrush = new SolidColorBrush(Color.Parse("#888888"));
17+
private static readonly IBrush _normalBrush = Brushes.White;
1018

1119
public OperationOutputViewModel(AbstractOperation operation)
1220
{
1321
Title = operation.Metadata.Title;
14-
OutputText = string.Join("\n", operation.GetOutput().Select(x => x.Item1));
22+
23+
foreach (var (text, type) in operation.GetOutput())
24+
OutputLines.Add(MakeLine(text, type));
25+
26+
operation.LogLineAdded += (_, ev) =>
27+
Dispatcher.UIThread.Post(() => OutputLines.Add(MakeLine(ev.Item1, ev.Item2)));
28+
}
29+
30+
private LogLineItem MakeLine(string text, AbstractOperation.LineType type)
31+
{
32+
IBrush brush = type switch
33+
{
34+
AbstractOperation.LineType.Error => _errorBrush,
35+
AbstractOperation.LineType.VerboseDetails => _debugBrush,
36+
_ => _normalBrush,
37+
};
38+
return new LogLineItem(text, brush);
1539
}
1640
}

src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ public partial class MainWindowViewModel : ViewModelBase
6464
[ObservableProperty]
6565
private bool _operationsPanelVisible;
6666

67+
[ObservableProperty]
68+
private bool _operationsPanelExpanded = true;
69+
70+
[RelayCommand]
71+
private void ToggleOperationsPanel() => OperationsPanelExpanded = !OperationsPanelExpanded;
72+
73+
[RelayCommand]
74+
private void RetryFailedOperations() => AvaloniaOperationRegistry.RetryFailed();
75+
76+
[RelayCommand]
77+
private void ClearSuccessfulOperations() => AvaloniaOperationRegistry.ClearSuccessful();
78+
79+
[RelayCommand]
80+
private void ClearFinishedOperations() => AvaloniaOperationRegistry.ClearFinished();
81+
82+
[RelayCommand]
83+
private void CancelAllOperations() => AvaloniaOperationRegistry.CancelAll();
84+
6785
// ─── Sidebar ─────────────────────────────────────────────────────────────
6886
public SidebarViewModel Sidebar { get; } = new();
6987

src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ private async Task DoCloudBackup()
224224
{
225225
_isLoading = true;
226226
UpdateCloudControlsEnabled();
227+
try { await DoCloudBackupStatic(); }
228+
finally
229+
{
230+
_isLoading = false;
231+
UpdateCloudControlsEnabled();
232+
}
233+
}
234+
235+
public static async Task DoCloudBackupStatic()
236+
{
227237
try
228238
{
229239
var packages = InstalledPackagesLoader.Instance?.Packages.ToList() ?? [];
@@ -236,11 +246,6 @@ private async Task DoCloudBackup()
236246
Logger.Error("An error occurred while performing a CLOUD backup:");
237247
Logger.Error(ex);
238248
}
239-
finally
240-
{
241-
_isLoading = false;
242-
UpdateCloudControlsEnabled();
243-
}
244249
}
245250

246251
[RelayCommand]

0 commit comments

Comments
 (0)