@@ -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.
0 commit comments