feat(desktop): add proactive token refresh on app startup and activation

- Refresh token on every app launch (with 5-minute debounce to prevent rapid restarts)
- Trigger refresh on Mac app activation (Dock click) with same debounce
- Track lastRefreshAt timestamp in encrypted token storage
- Add isRemoteServerConfigured() helper method to avoid code duplication
- Fix cloud mode check that incorrectly required remoteServerUrl
- Add comprehensive tests for all refresh scenarios
This commit is contained in:
Innei
2026-02-02 22:34:37 +08:00
committed by arvinxx
parent dad6108a37
commit 6f24e6b900
7 changed files with 505 additions and 62 deletions

View File

@@ -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<void> {
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<void> {
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);
}
}
}

View File

@@ -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<boolean> {
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() {

View File

@@ -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}`,

View File

@@ -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();
});
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
}
};
/**

View File

@@ -5,6 +5,7 @@ export interface ElectronMainStore {
encryptedTokens: {
accessToken?: string;
expiresAt?: number;
lastRefreshAt?: number;
refreshToken?: string;
};
locale: string;