11using System . Diagnostics ;
2+ using System . Net . Http ;
23using DynamicData . Aggregation ;
34using JetBrains . Annotations ;
45using Microsoft . Extensions . Logging ;
@@ -39,6 +40,29 @@ public sealed class LoginManager : IDisposable, ILoginManager
3940
4041private readonly IDisposable _observeDatomDisposable ;
4142
43+ // Timing Constants
44+
45+ /// <summary>
46+ /// How long UserInfo is cached before requiring refresh (in minutes).
47+ /// </summary>
48+ private const int CacheExpiryMinutes = 60 ;
49+
50+ /// <summary>
51+ /// How often to proactively refresh UserInfo to prevent cache expiry (in minutes).
52+ /// Must be less than CacheExpiryMinutes to prevent cache expiration.
53+ /// </summary>
54+ private const int PeriodicRefreshIntervalMinutes = 59 ;
55+
56+ /// <summary>
57+ /// Maximum number of retry attempts when refreshing UserInfo fails.
58+ /// </summary>
59+ private const int MaxRefreshRetries = 3 ;
60+
61+ /// <summary>
62+ /// Initial delay between retry attempts when refreshing UserInfo fails (in seconds).
63+ /// </summary>
64+ private const int InitialRetryDelaySeconds = 3 ;
65+
4266/// <summary>
4367/// Constructor.
4468/// </summary>
@@ -76,10 +100,19 @@ public LoginManager(
76100_userInfo . OnNext ( userInfo ) ;
77101}
78102} , awaitOperation : AwaitOperation . Sequential , configureAwait : false ) ;
103+
104+ // Set up periodic refresh to prevent cache expiry
105+ _periodicRefreshDisposable = Observable
106+ . Timer ( TimeSpan . FromMinutes ( PeriodicRefreshIntervalMinutes ) , TimeSpan . FromMinutes ( PeriodicRefreshIntervalMinutes ) )
107+ . SubscribeAwait ( async ( _ , cancellationToken ) =>
108+ {
109+ await TryRefreshUserInfoSafely ( cancellationToken , "periodic update" ) ;
110+ } , configureAwait : false ) ;
79111}
80112
81- private CachedObject < UserInfo > _cachedUserInfo = new ( TimeSpan . FromHours ( 1 ) ) ;
113+ private CachedObject < UserInfo > _cachedUserInfo = new ( TimeSpan . FromMinutes ( CacheExpiryMinutes ) ) ;
82114private readonly SemaphoreSlim _verifySemaphore = new ( initialCount : 1 , maxCount : 1 ) ;
115+ private readonly IDisposable _periodicRefreshDisposable ;
83116private readonly IConnection _conn ;
84117
85118private async ValueTask < UserInfo ? > Verify ( CancellationToken cancellationToken )
@@ -101,6 +134,63 @@ public LoginManager(
101134return userInfo ;
102135}
103136
137+ private async Task RefreshUserInfoWithRetry ( CancellationToken cancellationToken )
138+ {
139+ const int maxRetries = MaxRefreshRetries ;
140+ var delay = TimeSpan . FromSeconds ( InitialRetryDelaySeconds ) ;
141+
142+ for ( var attempt = 0 ; attempt < maxRetries ; attempt ++ )
143+ {
144+ try
145+ {
146+ // Force cache eviction to trigger a fresh API call
147+ _cachedUserInfo . Evict ( ) ;
148+ var userInfo = await Verify ( cancellationToken ) ;
149+
150+ if ( userInfo is null )
151+ continue ;
152+
153+ _cachedUserInfo . Store ( userInfo ) ;
154+ _userInfo . OnNext ( userInfo ) ;
155+ return ; // Success, exit retry loop
156+ }
157+ catch ( TaskCanceledException )
158+ {
159+ // Cancellation requested, exit gracefully
160+ return ;
161+ }
162+ catch ( Exception ex ) when ( attempt < maxRetries - 1 )
163+ {
164+ _logger . LogWarning ( ex , "Error refreshing user info, attempt {Attempt}/{MaxAttempts}" ,
165+ attempt + 1 , maxRetries ) ;
166+
167+ // Exponential backoff for all retryable exceptions
168+ // Base delay: 3s, multiplied by 2^attempt
169+ // Attempt 0: 3s, Attempt 1: 6s, Attempt 2: 12s
170+ // Total delay if all retries fail: 21 seconds
171+ var exponentialDelay = TimeSpan . FromMilliseconds (
172+ delay . TotalMilliseconds * Math . Pow ( 2 , attempt ) ) ;
173+ await Task . Delay ( exponentialDelay , cancellationToken ) ;
174+ }
175+ }
176+
177+ _logger . LogWarning ( "Failed to refresh user info after {MaxAttempts} attempts" , maxRetries ) ;
178+ }
179+
180+ private async Task TryRefreshUserInfoSafely ( CancellationToken cancellationToken , string context )
181+ {
182+ try
183+ {
184+ // Only refresh if we have a cached value (user is logged in)
185+ if ( _cachedUserInfo . Get ( ) is notnull )
186+ await RefreshUserInfoWithRetry ( cancellationToken ) ;
187+ }
188+ catch ( Exception ex )
189+ {
190+ _logger . LogWarning ( ex , "Failed to refresh user info during {Context}" , context ) ;
191+ }
192+ }
193+
104194private async ValueTask AddUserToDb ( UserInfo userInfo )
105195{
106196using var tx = _conn . BeginTransaction ( ) ;
@@ -136,6 +226,22 @@ public async Task<bool> GetIsUserLoggedInAsync(CancellationToken token = default
136226return await GetUserInfoAsync ( token ) is notnull ;
137227}
138228
229+ /// <summary>
230+ /// Enables automatic refresh of user info based on an observable boolean trigger.
231+ /// </summary>
232+ /// <param name="triggerObservable">Observable that triggers refresh when true</param>
233+ /// <returns>IDisposable to stop the refresh subscription</returns>
234+ public IDisposable RefreshOnObservable ( Observable < bool > triggerObservable )
235+ {
236+ return triggerObservable
237+ . DistinctUntilChanged ( )
238+ . Where ( isActive=> isActive )
239+ . SubscribeAwait ( async ( _ , cancellationToken ) =>
240+ {
241+ await TryRefreshUserInfoSafely ( cancellationToken , "window focus" ) ;
242+ } , configureAwait : false ) ;
243+ }
244+
139245/// <summary>
140246/// Show a browser and log into Nexus Mods
141247/// </summary>
@@ -212,5 +318,6 @@ public void Dispose()
212318{
213319_verifySemaphore . Dispose ( ) ;
214320_observeDatomDisposable . Dispose ( ) ;
321+ _periodicRefreshDisposable . Dispose ( ) ;
215322}
216323}