Skip to content

Commit fd81227

Browse files
authored
Add an optional installer-host column to package lists (#5320)
1 parent d23150c commit fd81227

15 files changed

Lines changed: 652 additions & 23 deletions

File tree

src/Languages/lang_en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,5 +998,7 @@
998998
"Name given by the publisher": "Name given by the publisher",
999999
"Package name and version": "Package name and version",
10001000
"Package identifier and version": "Package identifier and version",
1001-
"Name given by the publisher, followed by the version": "Name given by the publisher, followed by the version"
1001+
"Name given by the publisher, followed by the version": "Name given by the publisher, followed by the version",
1002+
"Installer host": "Installer host",
1003+
"Show the installer host on package lists": "Show the installer host on package lists"
10021004
}

src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ private sealed record Entry(string Title, string[] Keywords, Type PageType, stri
5454
new("Manage UniGetUI autostart behaviour", ["autostart", "run at login", "startup"], typeof(Interface_P), "EditAutostartSettings"),
5555
new("Show package icons on package lists", ["package icons"], typeof(Interface_P), "InterfacePackageListsCard"),
5656
new("Show illustrations on package lists", ["illustrations", "package illustrations"], typeof(Interface_P), "PackageIllustrationsCard"),
57+
new("Show the installer host on package lists", ["installer host", "download host", "installer url", "column"], typeof(Interface_P), "InstallerHostColumnCard"),
5758
new("Clear the icon cache", ["icon cache", "clear cache", "cache size"], typeof(Interface_P), "ResetIconCache"),
5859
new("Select upgradable packages by default", ["select updates", "select upgradable"], typeof(Interface_P), "SelectUpgradableCard"),
5960
new("User interface preferences", ["interface", "ui"], typeof(Interface_P), null),

src/UniGetUI.Avalonia/Models/PackageCollections.cs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable
4646
private const int MaxFailedIconEntries = 512;
4747
private static readonly TimeSpan IconRetryInterval = TimeSpan.FromMinutes(5);
4848

49+
private const string UnknownInstallerHost = "\u2014";
50+
private const int MaxInstallerHostCacheEntries = 1024;
51+
private const int MaxUnresolvedInstallerHostEntries = 512;
52+
private static readonly TimeSpan InstallerHostRetryInterval = TimeSpan.FromMinutes(5);
53+
private static readonly SemaphoreSlim _installerHostSemaphore = new(4, 4);
54+
private static readonly object _installerHostCacheLock = new();
55+
private static readonly Dictionary<long, (string Host, string Urls)> _installerHostCache = new();
56+
private static readonly Dictionary<long, long> _unresolvedInstallerHosts = new();
57+
4958
private static Bitmap? GetCachedIcon(long hash)
5059
{
5160
lock (_iconCacheLock)
@@ -178,6 +187,9 @@ public bool IsChecked
178187
public bool InstallerHostChanged { get; private set; }
179188
public string InstallerHostChangeTooltip { get; private set; } = "";
180189

190+
public string InstallerHostText { get; private set; } = "";
191+
public string? InstallerHostTooltip { get; private set; }
192+
181193
private CancellationTokenSource? _installerHostCheckCts;
182194
// Cancels this row's queued/in-flight icon load on disposal so it stops rooting the wrapper.
183195
private readonly CancellationTokenSource _lifetimeCts = new();
@@ -232,6 +244,161 @@ public Task EnsureIconLoadedAsync()
232244
return _iconLoadTask ??= LoadIconAsync();
233245
}
234246

247+
private int _installerHostLoadStarted;
248+
249+
public void EnsureInstallerHostLoaded()
250+
{
251+
if (!_page.InstallerHostColumnVisible) return;
252+
if (Interlocked.Exchange(ref _installerHostLoadStarted, 1) != 0) return;
253+
_ = LoadInstallerHostAsync();
254+
}
255+
256+
private string InstallerHostVersion =>
257+
Package.IsUpgradable ? Package.NewVersionString : Package.VersionString;
258+
259+
private async Task LoadInstallerHostAsync()
260+
{
261+
CancellationToken token = _lifetimeCts.Token;
262+
long hash = CoreTools.HashStringAsLong(
263+
$"{Package.GetVersionedHash()}|{InstallerHostVersion}"
264+
);
265+
try
266+
{
267+
if (TryGetCachedInstallerHost(hash, out var cached))
268+
{
269+
ApplyInstallerHost(cached.Host, cached.Urls);
270+
return;
271+
}
272+
273+
if (HasRecentInstallerHostFailure(hash))
274+
{
275+
ApplyInstallerHost("", "");
276+
return;
277+
}
278+
279+
await _installerHostSemaphore.WaitAsync(token).ConfigureAwait(false);
280+
(string Host, string Urls) resolved;
281+
try
282+
{
283+
if (!TryGetCachedInstallerHost(hash, out resolved))
284+
{
285+
IReadOnlyList<string>? urls = await ResolveInstallerUrlsAsync(token)
286+
.ConfigureAwait(false);
287+
resolved = (
288+
InstallerHostDisplay.FromUrls(urls),
289+
InstallerHostDisplay.JoinUrls(urls)
290+
);
291+
if (resolved.Host.Length > 0)
292+
CacheInstallerHost(hash, resolved.Host, resolved.Urls);
293+
else
294+
MarkInstallerHostUnresolved(hash);
295+
}
296+
}
297+
finally
298+
{
299+
_installerHostSemaphore.Release();
300+
}
301+
302+
if (token.IsCancellationRequested) return;
303+
await Dispatcher.UIThread.InvokeAsync(() =>
304+
{
305+
if (!token.IsCancellationRequested)
306+
ApplyInstallerHost(resolved.Host, resolved.Urls);
307+
});
308+
}
309+
catch (OperationCanceledException) { }
310+
catch (Exception ex)
311+
{
312+
Logger.Warn($"Could not resolve the installer host for {Package.Id}: {ex.Message}");
313+
}
314+
finally
315+
{
316+
Interlocked.Exchange(ref _installerHostLoadStarted, 0);
317+
}
318+
}
319+
320+
private async Task<IReadOnlyList<string>?> ResolveInstallerUrlsAsync(CancellationToken token)
321+
{
322+
token.ThrowIfCancellationRequested();
323+
#if WINDOWS
324+
if (Package.Manager is WinGet)
325+
{
326+
string version = InstallerHostVersion;
327+
return await Task.Run(() => WinGet.TryGetInstallerUrls(Package, version), token)
328+
.ConfigureAwait(false);
329+
}
330+
#endif
331+
if (!Package.Details.IsPopulated)
332+
await Package.Details.Load().ConfigureAwait(false);
333+
334+
return Package.Details.InstallerUrl is { } url ? [url.ToString()] : null;
335+
}
336+
337+
private void ApplyInstallerHost(string host, string urls)
338+
{
339+
InstallerHostText = host.Length > 0 ? host : UnknownInstallerHost;
340+
InstallerHostTooltip = urls.Length > 0 ? urls : null;
341+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InstallerHostText)));
342+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InstallerHostTooltip)));
343+
}
344+
345+
private static bool TryGetCachedInstallerHost(long hash, out (string Host, string Urls) entry)
346+
{
347+
lock (_installerHostCacheLock)
348+
return _installerHostCache.TryGetValue(hash, out entry);
349+
}
350+
351+
private static bool HasRecentInstallerHostFailure(long hash)
352+
{
353+
lock (_installerHostCacheLock)
354+
{
355+
if (!_unresolvedInstallerHosts.TryGetValue(hash, out long failedAt))
356+
return false;
357+
358+
if (Environment.TickCount64 - failedAt < (long)InstallerHostRetryInterval.TotalMilliseconds)
359+
return true;
360+
361+
_unresolvedInstallerHosts.Remove(hash);
362+
return false;
363+
}
364+
}
365+
366+
private static void MarkInstallerHostUnresolved(long hash)
367+
{
368+
lock (_installerHostCacheLock)
369+
{
370+
long now = Environment.TickCount64;
371+
_unresolvedInstallerHosts[hash] = now;
372+
if (_unresolvedInstallerHosts.Count <= MaxUnresolvedInstallerHostEntries)
373+
return;
374+
375+
long retryMs = (long)InstallerHostRetryInterval.TotalMilliseconds;
376+
foreach (var expired in _unresolvedInstallerHosts.Where(e => now - e.Value >= retryMs).ToArray())
377+
_unresolvedInstallerHosts.Remove(expired.Key);
378+
379+
int excess = _unresolvedInstallerHosts.Count - MaxUnresolvedInstallerHostEntries;
380+
if (excess <= 0)
381+
return;
382+
383+
foreach (var oldest in _unresolvedInstallerHosts.OrderBy(e => e.Value).Take(excess).ToArray())
384+
_unresolvedInstallerHosts.Remove(oldest.Key);
385+
}
386+
}
387+
388+
private static void CacheInstallerHost(long hash, string host, string urls)
389+
{
390+
lock (_installerHostCacheLock)
391+
{
392+
if (_installerHostCache.Count >= MaxInstallerHostCacheEntries
393+
&& !_installerHostCache.ContainsKey(hash))
394+
{
395+
_installerHostCache.Clear();
396+
}
397+
398+
_installerHostCache[hash] = (host, urls);
399+
}
400+
}
401+
235402
/// <summary>
236403
/// For upgradable WinGet packages, asynchronously fetches the installer URL host for
237404
/// both the installed and the new version, and flags the row when the hosts differ.

src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ partial void OnIsFilterPaneOpenChanged(bool value)
146146
public readonly bool LoadsOnStart;
147147
public readonly bool RoleIsUpdateLike;
148148
public bool SimilarSearchEnabled { get; private set; }
149+
public bool InstallerHostColumnVisible { get; }
149150
public readonly string NoPackagesText;
150151
public readonly string NoMatchesText;
151152
public readonly string SearchBoxPlaceholder;
@@ -189,6 +190,7 @@ partial void OnIsFilterPaneOpenChanged(bool value)
189190
[ObservableProperty] private string _versionHeaderText = "";
190191
[ObservableProperty] private string _newVersionHeaderText = "";
191192
[ObservableProperty] private string _sourceHeaderText = "";
193+
[ObservableProperty] private string _installerHostHeaderText = "";
192194

193195
// ─── Collections ──────────────────────────────────────────────────────────
194196
public ObservablePackageCollection FilteredPackages { get; } = new();
@@ -248,6 +250,8 @@ public PackagesPageViewModel(PackagesPageData data)
248250
SimilarSearchEnabled = !data.DisableSuggestedResultsRadio;
249251
RoleIsUpdateLike = data.PageRole == OperationType.Update;
250252
NewVersionHeaderVisible = RoleIsUpdateLike;
253+
InstallerHostColumnVisible = Settings.Get(Settings.K.ShowInstallerHostColumn)
254+
&& data.PageRole != OperationType.Uninstall;
251255
ReloadButtonVisible = !DisableReload;
252256
SearchBoxPlaceholder = CoreTools.Translate("Search for packages");
253257

@@ -899,6 +903,7 @@ public void UpdateHeaderTexts()
899903
VersionHeaderText = isList ? CoreTools.Translate("Version") : "";
900904
NewVersionHeaderText = isList ? CoreTools.Translate("New version") : "";
901905
SourceHeaderText = isList ? CoreTools.Translate("Source") : "";
906+
InstallerHostHeaderText = isList ? CoreTools.Translate("Installer host") : "";
902907
}
903908

904909
public bool IsListViewMode => ViewMode == PackageViewMode.List;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using UniGetUI.PackageEngine.PackageClasses;
5+
6+
namespace UniGetUI.Avalonia.Views.Controls;
7+
8+
public static class PackageInstallerHostLoader
9+
{
10+
public static readonly AttachedProperty<bool> TrackProperty =
11+
AvaloniaProperty.RegisterAttached<Control, bool>("Track", typeof(PackageInstallerHostLoader));
12+
13+
public static void SetTrack(Control control, bool value) => control.SetValue(TrackProperty, value);
14+
public static bool GetTrack(Control control) => control.GetValue(TrackProperty);
15+
16+
static PackageInstallerHostLoader()
17+
{
18+
TrackProperty.Changed.AddClassHandler<Control>((control, e) =>
19+
{
20+
if (e.GetNewValue<bool>())
21+
{
22+
control.AttachedToVisualTree += OnAttached;
23+
control.DataContextChanged += OnDataContextChanged;
24+
if (control.IsLoaded) TryLoad(control);
25+
}
26+
else
27+
{
28+
control.AttachedToVisualTree -= OnAttached;
29+
control.DataContextChanged -= OnDataContextChanged;
30+
}
31+
});
32+
}
33+
34+
private static void OnAttached(object? sender, VisualTreeAttachmentEventArgs e)
35+
=> TryLoad((Control)sender!);
36+
37+
private static void OnDataContextChanged(object? sender, EventArgs e)
38+
{
39+
var control = (Control)sender!;
40+
if (control.IsLoaded) TryLoad(control);
41+
}
42+
43+
private static void TryLoad(Control control)
44+
{
45+
if (control.DataContext is PackageWrapper wrapper) wrapper.EnsureInstallerHostLoaded();
46+
}
47+
}

src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@
100100
CornerRadius="0"
101101
BorderThickness="1,0,1,1"/>
102102

103+
<settings:CheckboxCard x:Name="InstallerHostColumnCard"
104+
SettingName="ShowInstallerHostColumn"
105+
Text="{t:Translate Show the installer host on package lists}"
106+
WarningText="{t:Translate Restart UniGetUI to apply this change}"
107+
StateChangedCommand="{Binding ShowRestartRequiredCommand}"
108+
CornerRadius="0"
109+
BorderThickness="1,0,1,1"/>
110+
103111
<settings:ButtonCard x:Name="ResetIconCache"
104112
CornerRadius="0,0,8,8"
105113
BorderThickness="1,0,1,1"

src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,29 @@
650650
</DataTemplate>
651651
</DataGridTemplateColumn.CellTemplate>
652652
</DataGridTemplateColumn>
653+
<DataGridTemplateColumn CanUserSort="False"
654+
Header="{Binding $parent[UserControl].((vm:PackagesPageViewModel)DataContext).InstallerHostHeaderText}"
655+
Width="*"
656+
IsVisible="{Binding $parent[UserControl].((vm:PackagesPageViewModel)DataContext).InstallerHostColumnVisible}">
657+
<DataGridTemplateColumn.CellTemplate>
658+
<DataTemplate x:DataType="pkg:PackageWrapper">
659+
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" Margin="4,0"
660+
controls:PackageInstallerHostLoader.Track="True">
661+
<controls:SvgIcon Path="avares://UniGetUI/Assets/Symbols/launch.svg"
662+
Width="24" Height="24" VerticalAlignment="Center"
663+
IsVisible="{Binding !InstallerHostChanged}"/>
664+
<controls:SvgIcon Path="avares://UniGetUI/Assets/Symbols/warning_filled.svg"
665+
Width="22" Height="22" VerticalAlignment="Center"
666+
Foreground="#F59E0B"
667+
IsVisible="{Binding InstallerHostChanged}"
668+
ToolTip.Tip="{Binding InstallerHostChangeTooltip}"/>
669+
<TextBlock Text="{Binding InstallerHostText}" VerticalAlignment="Center"
670+
TextTrimming="CharacterEllipsis"
671+
ToolTip.Tip="{Binding InstallerHostTooltip}"/>
672+
</StackPanel>
673+
</DataTemplate>
674+
</DataGridTemplateColumn.CellTemplate>
675+
</DataGridTemplateColumn>
653676
</DataGrid.Columns>
654677
</DataGrid>
655678

src/UniGetUI.Core.Settings/SettingsEngine_Names.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ public enum K
105105
WinGetDownloadFullManifest,
106106
InstallerFileNameScheme,
107107
DisableInstallerHostChangeWarning,
108+
ShowInstallerHostColumn,
108109
BunPreferLatestVersions,
109110
RedactUsernameInLog,
110111
DisableReleaseNotesOnUpdate,
@@ -233,6 +234,7 @@ public static string ResolveKey(K key)
233234
K.WinGetDownloadFullManifest => "WinGetDownloadFullManifest",
234235
K.InstallerFileNameScheme => "InstallerFileNameScheme",
235236
K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning",
237+
K.ShowInstallerHostColumn => "ShowInstallerHostColumn",
236238
K.BunPreferLatestVersions => "BunPreferLatestVersions",
237239
K.RedactUsernameInLog => "RedactUsernameInLog",
238240
K.DisableReleaseNotesOnUpdate => "DisableReleaseNotesOnUpdate",

0 commit comments

Comments
 (0)