diff --git a/apps/desktop/src/main/controllers/AuthCtr.ts b/apps/desktop/src/main/controllers/AuthCtr.ts index c375672c09..ebac9e1d62 100644 --- a/apps/desktop/src/main/controllers/AuthCtr.ts +++ b/apps/desktop/src/main/controllers/AuthCtr.ts @@ -1,5 +1,4 @@ import { - AuthorizationPhase, AuthorizationProgress, DataSyncConfig, MarketAuthorizationParams, @@ -18,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr'); const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX) const POLL_INTERVAL = 3000; // 3 seconds +const TOKEN_REFRESH_DEBOUNCE = 5 * 60 * 1000; // 5 minutes - debounce interval to prevent excessive refreshes on rapid app restarts /** * Authentication Controller @@ -289,34 +289,35 @@ export default class AuthCtr extends ControllerModule { this.autoRefreshTimer = setInterval(async () => { try { // Check if token is expiring soon (refresh 5 minutes in advance) - if (this.remoteServerConfigCtr.isTokenExpiringSoon()) { - const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt(); - logger.info( - `Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`, - ); + if (!this.remoteServerConfigCtr.isTokenExpiringSoon()) { + return; + } + const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt(); + logger.info( + `Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`, + ); - const result = await this.remoteServerConfigCtr.refreshAccessToken(); - if (result.success) { - logger.info('Auto-refresh successful'); - this.broadcastTokenRefreshed(); + const result = await this.remoteServerConfigCtr.refreshAccessToken(); + if (result.success) { + logger.info('Auto-refresh successful'); + this.broadcastTokenRefreshed(); + } else { + logger.error(`Auto-refresh failed after retries: ${result.error}`); + + // Only clear tokens for non-retryable errors (e.g., invalid_grant) + // The retry mechanism in RemoteServerConfigCtr already handles transient errors + if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) { + logger.warn( + 'Non-retryable error detected, clearing tokens and requiring re-authorization', + ); + this.stopAutoRefresh(); + await this.remoteServerConfigCtr.clearTokens(); + await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false }); + this.broadcastAuthorizationRequired(); } else { - logger.error(`Auto-refresh failed after retries: ${result.error}`); - - // Only clear tokens for non-retryable errors (e.g., invalid_grant) - // The retry mechanism in RemoteServerConfigCtr already handles transient errors - if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) { - logger.warn( - 'Non-retryable error detected, clearing tokens and requiring re-authorization', - ); - this.stopAutoRefresh(); - await this.remoteServerConfigCtr.clearTokens(); - await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false }); - this.broadcastAuthorizationRequired(); - } else { - // For other errors (after retries exhausted), log but don't clear tokens immediately - // The next refresh cycle will retry - logger.warn('Refresh failed but error may be transient, will retry on next cycle'); - } + // For other errors (after retries exhausted), log but don't clear tokens immediately + // The next refresh cycle will retry + logger.warn('Refresh failed but error may be transient, will retry on next cycle'); } } } catch (error) { @@ -663,13 +664,14 @@ export default class AuthCtr extends ControllerModule { /** * Initialize auto-refresh functionality * Checks for valid token at app startup and starts auto-refresh timer if token exists + * Proactively refreshes token on every startup (with 5-minute debounce to prevent rapid restart issues) */ private async initializeAutoRefresh() { try { const config = await this.remoteServerConfigCtr.getRemoteServerConfig(); // Check if remote server is configured and active - if (!config.active || !config.remoteServerUrl) { + if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) { logger.debug( 'Remote server not active or configured, skipping auto-refresh initialization', ); @@ -690,44 +692,121 @@ export default class AuthCtr extends ControllerModule { return; } - // Check if token has already expired const currentTime = Date.now(); + + // Check if token has already expired if (currentTime >= expiresAt) { logger.info('Token has expired, attempting to refresh it'); + await this.performProactiveRefresh(); + return; + } - // Attempt to refresh token (includes retry mechanism) - const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken(); - if (refreshResult.success) { - logger.info('Token refresh successful during initialization'); - this.broadcastTokenRefreshed(); - // Restart auto-refresh timer - this.startAutoRefresh(); - return; - } else { - logger.error(`Token refresh failed during initialization: ${refreshResult.error}`); - - // Only clear token for non-retryable errors - if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) { - logger.warn('Non-retryable error during initialization, clearing tokens'); - await this.remoteServerConfigCtr.clearTokens(); - await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false }); - this.broadcastAuthorizationRequired(); - } else { - // For transient errors, still start auto-refresh timer to retry later - logger.warn('Transient error during initialization, will retry via auto-refresh'); - this.startAutoRefresh(); - } - return; - } + // Proactively refresh token if it hasn't been refreshed in the last 6 hours + // This ensures token validity even if the server has revoked it + if (this.shouldProactivelyRefresh()) { + logger.info('Token refresh interval exceeded, proactively refreshing token on startup'); + await this.performProactiveRefresh(); + return; } // Start auto-refresh timer logger.info( - `Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`, + `Token is valid and recently refreshed, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`, ); this.startAutoRefresh(); } catch (error) { logger.error('Error during auto-refresh initialization:', error); } } + + /** + * Check if token should be proactively refreshed + * Returns true if the token hasn't been refreshed recently (within debounce interval) + * This ensures we refresh on every app launch while preventing excessive refreshes on rapid restarts + */ + private shouldProactivelyRefresh(): boolean { + const lastRefreshAt = this.remoteServerConfigCtr.getLastTokenRefreshAt(); + + // If never refreshed, should refresh + if (!lastRefreshAt) { + logger.debug('No last refresh time found, should proactively refresh'); + return true; + } + + const timeSinceLastRefresh = Date.now() - lastRefreshAt; + const shouldRefresh = timeSinceLastRefresh >= TOKEN_REFRESH_DEBOUNCE; + + if (shouldRefresh) { + logger.debug( + `Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, exceeds ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold`, + ); + } else { + logger.debug( + `Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, within ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold, skipping refresh`, + ); + } + + return shouldRefresh; + } + + /** + * Perform proactive token refresh (used on startup and app activation) + */ + private async performProactiveRefresh(): Promise { + const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken(); + if (refreshResult.success) { + logger.info('Proactive token refresh successful'); + this.broadcastTokenRefreshed(); + this.startAutoRefresh(); + } else { + logger.error(`Proactive token refresh failed: ${refreshResult.error}`); + + // Only clear token for non-retryable errors + if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) { + logger.warn('Non-retryable error during proactive refresh, clearing tokens'); + await this.remoteServerConfigCtr.clearTokens(); + await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false }); + this.broadcastAuthorizationRequired(); + } else { + // For transient errors, still start auto-refresh timer to retry later + logger.warn('Transient error during proactive refresh, will retry via auto-refresh'); + this.startAutoRefresh(); + } + } + } + + /** + * Handle app activation event (e.g., Mac dock click, window focus) + * Proactively refresh token if needed (respects 6-hour interval) + */ + async onAppActivate(): Promise { + logger.debug('App activated, checking if token refresh is needed'); + + try { + const config = await this.remoteServerConfigCtr.getRemoteServerConfig(); + + // Check if remote server is configured and active + if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) { + logger.debug('Remote server not active, skipping activation refresh'); + return; + } + + // Check if valid access token exists + const accessToken = await this.remoteServerConfigCtr.getAccessToken(); + if (!accessToken) { + logger.debug('No access token found, skipping activation refresh'); + return; + } + + // Only refresh if interval has passed + if (this.shouldProactivelyRefresh()) { + logger.info('Token refresh interval exceeded on app activation, refreshing token'); + await this.performProactiveRefresh(); + } else { + logger.debug('Token was recently refreshed, skipping activation refresh'); + } + } catch (error) { + logger.error('Error during app activation refresh check:', error); + } + } } diff --git a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts index 3a886c3d14..cf8ece5552 100644 --- a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +++ b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts @@ -82,6 +82,21 @@ export default class RemoteServerConfigCtr extends ControllerModule { return normalized; } + /** + * Check if remote server is properly configured and ready for use + * For 'cloud' mode, only checks if active (remoteServerUrl is undefined, uses OFFICIAL_CLOUD_SERVER) + * For 'selfHost' mode, checks if active AND remoteServerUrl is configured + * @param config Optional config object, if not provided will fetch current config + * @returns true if remote server is properly configured + */ + async isRemoteServerConfigured(config?: DataSyncConfig): Promise { + const effectiveConfig = config ?? (await this.getRemoteServerConfig()); + return ( + effectiveConfig.active && + (effectiveConfig.storageMode !== 'selfHost' || !!effectiveConfig.remoteServerUrl) + ); + } + /** * Set remote server configuration */ @@ -139,6 +154,12 @@ export default class RemoteServerConfigCtr extends ControllerModule { */ private tokenExpiresAt?: number; + /** + * Last token refresh time (timestamp in milliseconds) + * Used to control refresh frequency on app startup/activate + */ + private lastRefreshAt?: number; + /** * Promise representing the ongoing token refresh operation. * Used to prevent concurrent refreshes and allow callers to wait. @@ -162,6 +183,10 @@ export default class RemoteServerConfigCtr extends ControllerModule { this.tokenExpiresAt = undefined; } + // Update last refresh time + this.lastRefreshAt = Date.now(); + logger.debug(`Token last refreshed at: ${new Date(this.lastRefreshAt).toISOString()}`); + // If platform doesn't support secure storage, store raw tokens if (!safeStorage.isEncryptionAvailable()) { logger.warn('Safe storage not available, storing tokens unencrypted'); @@ -171,6 +196,7 @@ export default class RemoteServerConfigCtr extends ControllerModule { this.app.storeManager.set(this.encryptedTokensKey, { accessToken: this.encryptedAccessToken, expiresAt: this.tokenExpiresAt, + lastRefreshAt: this.lastRefreshAt, refreshToken: this.encryptedRefreshToken, }); return; @@ -191,6 +217,7 @@ export default class RemoteServerConfigCtr extends ControllerModule { this.app.storeManager.set(this.encryptedTokensKey, { accessToken: this.encryptedAccessToken, expiresAt: this.tokenExpiresAt, + lastRefreshAt: this.lastRefreshAt, refreshToken: this.encryptedRefreshToken, }); } @@ -285,10 +312,10 @@ export default class RemoteServerConfigCtr extends ControllerModule { /** * Check if token is expired or will expire soon - * @param bufferTimeMs Buffer time in milliseconds (default 5 minutes) + * @param bufferTimeMs Buffer time in milliseconds (default 1 day) * @returns true if token is expired or will expire soon */ - isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean { + isTokenExpiringSoon(bufferTimeMs: number = 24 * 60 * 60 * 1000): boolean { if (!this.tokenExpiresAt) { return false; // No expiration time available } @@ -401,7 +428,7 @@ export default class RemoteServerConfigCtr extends ControllerModule { // Get configuration information const config = await this.getRemoteServerConfig(); - if (!config.remoteServerUrl || !config.active) { + if (!(await this.isRemoteServerConfigured(config))) { logger.warn('Remote server not active or configured, skipping refresh.'); return { error: 'Remote server is not active or configured', success: false }; } @@ -480,17 +507,29 @@ export default class RemoteServerConfigCtr extends ControllerModule { this.encryptedAccessToken = storedTokens.accessToken; this.encryptedRefreshToken = storedTokens.refreshToken; this.tokenExpiresAt = storedTokens.expiresAt; + this.lastRefreshAt = storedTokens.lastRefreshAt; if (this.tokenExpiresAt) { logger.debug( `Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`, ); } + if (this.lastRefreshAt) { + logger.debug(`Loaded last refresh time: ${new Date(this.lastRefreshAt).toISOString()}`); + } } else { logger.debug('No valid tokens found in store.'); } } + /** + * Get the last token refresh time + * @returns The timestamp (in milliseconds) of the last token refresh, or undefined if never refreshed + */ + getLastTokenRefreshAt(): number | undefined { + return this.lastRefreshAt; + } + // Initialize by loading tokens from store when the controller is ready // We might need a dedicated lifecycle method if constructor is too early for storeManager afterAppReady() { diff --git a/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts index a354017e4b..56196c972b 100644 --- a/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +++ b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts @@ -55,8 +55,7 @@ export default class RemoteServerSyncCtr extends ControllerModule { logger.debug(`${logPrefix} Received stream:start IPC call`); try { - const config = await this.remoteServerConfigCtr.getRemoteServerConfig(); - if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) { + if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured())) { logger.warn(`${logPrefix} Remote server sync not active or configured.`); event.sender.send( `stream:error:${requestId}`, diff --git a/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts index 8fc7b7f7ba..062ea9ce79 100644 --- a/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts @@ -76,6 +76,7 @@ vi.mock('node:crypto', () => ({ const mockRemoteServerConfigCtr = { clearTokens: vi.fn().mockResolvedValue(undefined), getAccessToken: vi.fn().mockResolvedValue('mock-access-token'), + getLastTokenRefreshAt: vi.fn().mockReturnValue(Date.now()), getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }), getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => { if (config?.storageMode === 'selfHost') { @@ -84,6 +85,8 @@ const mockRemoteServerConfigCtr = { return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER }), getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000), + isNonRetryableError: vi.fn().mockReturnValue(false), + isRemoteServerConfigured: vi.fn().mockResolvedValue(true), isTokenExpiringSoon: vi.fn().mockReturnValue(false), refreshAccessToken: vi.fn().mockResolvedValue({ success: true }), saveTokens: vi.fn().mockResolvedValue(undefined), @@ -711,4 +714,202 @@ describe('AuthCtr', () => { expect(handoffCalls.length).toBeLessThanOrEqual(5); }, 10000); }); + + describe('Proactive Token Refresh', () => { + const FIVE_MINUTES = 5 * 60 * 1000; // Debounce interval + + beforeEach(() => { + // Reset mocks for proactive refresh tests + vi.mocked(mockRemoteServerConfigCtr.getRemoteServerConfig).mockResolvedValue({ + active: true, + remoteServerUrl: 'https://lobehub-cloud.com', + storageMode: 'cloud', + }); + vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(true); + vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue('mock-access-token'); + vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue( + Date.now() + 3600000, // Token valid for 1 hour + ); + // Reset getLastTokenRefreshAt to a recent value by default + // Individual tests will override this as needed + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(Date.now()); + }); + + describe('onAppActivate', () => { + it('should refresh token when last refresh was more than 5 minutes ago', async () => { + // Last refresh was 10 minutes ago (exceeds 5-minute debounce) + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + success: true, + }); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled(); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('tokenRefreshed'); + }); + + it('should NOT refresh token when last refresh was within 5 minutes', async () => { + // Last refresh was 2 minutes ago (within 5-minute debounce) + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 2 * 60 * 1000, + ); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should refresh token when lastRefreshAt is undefined (never refreshed)', async () => { + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(undefined); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + success: true, + }); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled(); + }); + + it('should skip refresh when remote server is not active', async () => { + vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(false); + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should skip refresh when no access token exists', async () => { + vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null); + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should handle refresh failure with non-retryable error', async () => { + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + error: 'invalid_grant', + success: false, + }); + vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(true); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.clearTokens).toHaveBeenCalled(); + expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({ + active: false, + }); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired'); + }); + + it('should handle refresh failure with transient error (start auto-refresh)', async () => { + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + error: 'network_error', + success: false, + }); + vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(false); + + await authCtr.onAppActivate(); + + // Should not clear tokens for transient errors + expect(mockRemoteServerConfigCtr.clearTokens).not.toHaveBeenCalled(); + }); + }); + + describe('afterAppReady (initializeAutoRefresh)', () => { + it('should proactively refresh token on startup when debounce interval exceeded', async () => { + // Last refresh was 10 minutes ago (exceeds 5-minute debounce) + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 10 * 60 * 1000, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + success: true, + }); + + authCtr.afterAppReady(); + + // Wait for async initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled(); + }); + + it('should NOT refresh on startup when token was recently refreshed (within debounce)', async () => { + // Last refresh was 2 minutes ago (within 5-minute debounce) + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 2 * 60 * 1000, + ); + + authCtr.afterAppReady(); + + // Wait for async initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should refresh on startup when token is expired regardless of last refresh time', async () => { + // Token expired 1 hour ago + vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue( + Date.now() - 60 * 60 * 1000, + ); + // Last refresh was 2 minutes ago (within debounce, but token is expired) + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - 2 * 60 * 1000, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + success: true, + }); + + authCtr.afterAppReady(); + + // Wait for async initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled(); + }); + }); + + describe('refresh debounce boundary tests', () => { + it('should NOT refresh at exactly 5 minutes minus 1 second', async () => { + // Last refresh was 4 minutes 59 seconds ago + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - (FIVE_MINUTES - 1000), + ); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should refresh at exactly 5 minutes', async () => { + // Last refresh was exactly 5 minutes ago + vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue( + Date.now() - FIVE_MINUTES, + ); + vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ + success: true, + }); + + await authCtr.onAppActivate(); + + expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts index d96a5bc1ba..1161401e67 100644 --- a/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts @@ -335,10 +335,10 @@ describe('RemoteServerConfigCtr', () => { const { safeStorage } = await import('electron'); vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true); - // Token expires in 1 hour - await controller.saveTokens('access', 'refresh', 3600); + // Token expires in 2 days (well beyond the 24-hour default buffer) + await controller.saveTokens('access', 'refresh', 2 * 24 * 3600); - // Default buffer is 5 minutes + // Default buffer is 24 hours const result = controller.isTokenExpiringSoon(); expect(result).toBe(false); @@ -657,6 +657,56 @@ describe('RemoteServerConfigCtr', () => { // Verify tokens were loaded by checking getTokenExpiresAt expect(newController.getTokenExpiresAt()).toBeDefined(); }); + + it('should load lastRefreshAt from store', () => { + const lastRefreshTime = Date.now() - 3600000; // 1 hour ago + mockStoreManager.get.mockImplementation((key) => { + if (key === 'encryptedTokens') { + return { + accessToken: 'stored-access', + expiresAt: Date.now() + 3600000, + lastRefreshAt: lastRefreshTime, + refreshToken: 'stored-refresh', + }; + } + return { active: false, storageMode: 'cloud' }; + }); + + const newController = new RemoteServerConfigCtr(mockApp); + newController.afterAppReady(); + + // Verify lastRefreshAt was loaded + expect(newController.getLastTokenRefreshAt()).toBe(lastRefreshTime); + }); + }); + + describe('getLastTokenRefreshAt', () => { + it('should return undefined when no tokens have been saved', () => { + expect(controller.getLastTokenRefreshAt()).toBeUndefined(); + }); + + it('should return the last refresh time after saving tokens', async () => { + const beforeSave = Date.now(); + await controller.saveTokens('access', 'refresh', 3600); + const afterSave = Date.now(); + + const lastRefreshAt = controller.getLastTokenRefreshAt(); + + expect(lastRefreshAt).toBeDefined(); + expect(lastRefreshAt).toBeGreaterThanOrEqual(beforeSave); + expect(lastRefreshAt).toBeLessThanOrEqual(afterSave); + }); + + it('should persist lastRefreshAt to store when saving tokens', async () => { + await controller.saveTokens('access', 'refresh', 3600); + + expect(mockStoreManager.set).toHaveBeenCalledWith( + 'encryptedTokens', + expect.objectContaining({ + lastRefreshAt: expect.any(Number), + }), + ); + }); }); describe('getRemoteServerUrl', () => { @@ -695,4 +745,69 @@ describe('RemoteServerConfigCtr', () => { expect(result).toBe('https://custom-server.com'); }); }); + + describe('isRemoteServerConfigured', () => { + it('should return true for active cloud mode (no remoteServerUrl needed)', async () => { + mockStoreManager.get.mockReturnValue({ + active: true, + storageMode: 'cloud', + // remoteServerUrl is undefined for cloud mode + }); + + const result = await controller.isRemoteServerConfigured(); + + expect(result).toBe(true); + }); + + it('should return true for active selfHost mode with remoteServerUrl', async () => { + mockStoreManager.get.mockReturnValue({ + active: true, + remoteServerUrl: 'https://my-server.com', + storageMode: 'selfHost', + }); + + const result = await controller.isRemoteServerConfigured(); + + expect(result).toBe(true); + }); + + it('should return false for inactive config', async () => { + mockStoreManager.get.mockReturnValue({ + active: false, + storageMode: 'cloud', + }); + + const result = await controller.isRemoteServerConfigured(); + + expect(result).toBe(false); + }); + + it('should return false for selfHost mode without remoteServerUrl', async () => { + mockStoreManager.get.mockReturnValue({ + active: true, + storageMode: 'selfHost', + // remoteServerUrl is undefined + }); + + const result = await controller.isRemoteServerConfigured(); + + expect(result).toBe(false); + }); + + it('should use provided config instead of fetching', async () => { + // Store has inactive config + mockStoreManager.get.mockReturnValue({ + active: false, + storageMode: 'cloud', + }); + + // But we provide an active config + const result = await controller.isRemoteServerConfigured({ + active: true, + storageMode: 'cloud', + }); + + expect(result).toBe(true); + }); + }); }); diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts index da98c9d98e..da763ee561 100644 --- a/apps/desktop/src/main/core/App.ts +++ b/apps/desktop/src/main/core/App.ts @@ -10,6 +10,7 @@ import { buildDir } from '@/const/dir'; import { isDev } from '@/const/env'; import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol'; import { IControlModule } from '@/controllers'; +import AuthCtr from '@/controllers/AuthCtr'; import { IServiceModule } from '@/services'; import { createLogger } from '@/utils/logger'; @@ -251,6 +252,14 @@ export class App { private onActivate = () => { logger.debug('Application activated'); this.browserManager.showMainWindow(); + + // Trigger proactive token refresh on app activation (respects 6-hour interval) + const authCtr = this.getController(AuthCtr); + if (authCtr) { + authCtr.onAppActivate().catch((error) => { + logger.error('Error during app activation token refresh:', error); + }); + } }; /** diff --git a/apps/desktop/src/main/types/store.ts b/apps/desktop/src/main/types/store.ts index e5a9fada2d..71a23b4166 100644 --- a/apps/desktop/src/main/types/store.ts +++ b/apps/desktop/src/main/types/store.ts @@ -5,6 +5,7 @@ export interface ElectronMainStore { encryptedTokens: { accessToken?: string; expiresAt?: number; + lastRefreshAt?: number; refreshToken?: string; }; locale: string;