diff options
| -rw-r--r-- | CHANGELOG.md | 2 | ||||
| -rw-r--r-- | gui/src/main/account-data-cache.ts | 35 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 15 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/components/Account.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/Settings.tsx | 9 | ||||
| -rw-r--r-- | gui/src/renderer/containers/AccountPage.tsx | 1 | ||||
| -rw-r--r-- | gui/src/renderer/containers/SettingsPage.tsx | 1 | ||||
| -rw-r--r-- | gui/src/shared/account-expiry.ts | 4 | ||||
| -rw-r--r-- | gui/src/shared/ipc-schema.ts | 1 | ||||
| -rw-r--r-- | gui/test/account-data-cache.spec.ts | 123 |
11 files changed, 146 insertions, 54 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cccd59c0..ca79befb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ Line wrap the file at 100 chars. Th - Fix deadlock that may occur when the API cannot be reached while entering the connecting state. - Fix bug causing desktop app to log in if account number field was filled when removing account history. +- Fix lack of account expiry updates when using the app in unpinned mode and improve updating of + account expiry overall. #### Linux - Make offline monitor aware of routing table changes. diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts index 8b81a29472..a79a934537 100644 --- a/gui/src/main/account-data-cache.ts +++ b/gui/src/main/account-data-cache.ts @@ -6,18 +6,21 @@ import consumePromise from '../shared/promise'; import { Scheduler } from '../shared/scheduler'; import { InvalidAccountError } from './errors'; -const EXPIRED_ACCOUNT_REFRESH_PERIOD = 60_000; - interface IAccountFetchWatcher { onFinish: () => void; onError: (error: Error) => void; } +// Account data is valid for 1 minute unless the account has expired. +const ACCOUNT_DATA_VALIDITY_SECONDS = 60_000; +// Account data is valid for 10 seconds if the account has expired. +const ACCOUNT_DATA_EXPIRED_VALIDITY_SECONDS = 10_000; + // An account data cache that helps to throttle RPC requests to get_account_data and retain the // cached value for 1 minute. export default class AccountDataCache { private currentAccount?: AccountToken; - private expiresAt?: Date; + private validUntil?: Date; private performingFetch = false; private waitStrategy = new WaitStrategy(); private fetchRetryScheduler = new Scheduler(); @@ -36,7 +39,7 @@ export default class AccountDataCache { } // Only fetch if value has expired - if (this.isExpired()) { + if (!this.isValid()) { if (watcher) { this.watchers.push(watcher); } @@ -59,7 +62,7 @@ export default class AccountDataCache { this.waitStrategy.reset(); this.performingFetch = false; - this.expiresAt = undefined; + this.validUntil = undefined; this.updateHandler(); this.notifyWatchers((watcher) => { watcher.onError(new Error('Cancelled')); @@ -72,14 +75,22 @@ export default class AccountDataCache { } } - private setValue(value: IAccountData) { - this.expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration - this.updateHandler(value); + private setValue(accountData: IAccountData) { + this.validUntil = this.getValidUntil(accountData); + this.updateHandler(accountData); this.notifyWatchers((watcher) => watcher.onFinish()); } - private isExpired() { - return !this.expiresAt || this.expiresAt < new Date(); + private isValid() { + return this.validUntil && this.validUntil > new Date(); + } + + private getValidUntil(accountData: IAccountData): Date { + if (hasExpired(accountData.expiry)) { + return new Date(Date.now() + ACCOUNT_DATA_EXPIRED_VALIDITY_SECONDS); + } else { + return new Date(Date.now() + ACCOUNT_DATA_VALIDITY_SECONDS); + } } private async performFetch(accountToken: AccountToken) { @@ -113,9 +124,7 @@ export default class AccountDataCache { const currentDate = new Date(); const oneMinuteBeforeExpiry = dateByAddingComponent(accountExpiry, DateComponent.minute, -1); - if (hasExpired(accountExpiry)) { - return EXPIRED_ACCOUNT_REFRESH_PERIOD; - } else if (oneMinuteBeforeExpiry >= currentDate && closeToExpiry(accountExpiry)) { + if (oneMinuteBeforeExpiry >= currentDate && closeToExpiry(accountExpiry)) { return oneMinuteBeforeExpiry.getTime() - currentDate.getTime(); } else { return undefined; diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index a139434020..8b047cb6c3 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -15,7 +15,7 @@ import * as path from 'path'; import { sprintf } from 'sprintf-js'; import * as uuid from 'uuid'; import config from '../config.json'; -import { hasExpired } from '../shared/account-expiry'; +import { closeToExpiry, hasExpired } from '../shared/account-expiry'; import { IApplication } from '../shared/application-types'; import BridgeSettingsBuilder from '../shared/bridge-settings-builder'; import { @@ -1049,14 +1049,20 @@ class ApplicationMain { } private registerWindowListener(windowController: WindowController) { - windowController.window?.on('show', () => { + windowController.window?.on('focus', () => { // cancel notifications when window appears this.notificationController.cancelPendingNotifications(); - this.updateAccountData(); + if ( + !this.accountData || + closeToExpiry(this.accountData.expiry, 4) || + hasExpired(this.accountData.expiry) + ) { + this.updateAccountData(); + } }); - windowController.window?.on('hide', () => { + windowController.window?.on('blur', () => { // ensure notification guard is reset this.notificationController.resetTunnelStateAnnouncements(); }); @@ -1172,6 +1178,7 @@ class ApplicationMain { return response; }); + IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData()); IpcMainEventChannel.accountHistory.handleClear(async () => { await this.daemonRpc.clearAccountHistory(); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 677286c092..febcaf8bc8 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -318,6 +318,10 @@ export default class AppRenderer { return IpcRendererEventChannel.account.submitVoucher(voucherCode); } + public updateAccountData(): void { + IpcRendererEventChannel.account.updateData(); + } + public async connectTunnel(): Promise<void> { const state = this.tunnelState.state; diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index 18980ca1a9..02e2b9f081 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -31,9 +31,14 @@ interface IProps { onLogout: () => void; onClose: () => void; onBuyMore: () => Promise<void>; + updateAccountData: () => void; } export default class Account extends React.Component<IProps> { + public componentDidMount() { + this.props.updateAccountData(); + } + public render() { return ( <ModalContainer> diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx index 56b8fd5494..1610f776f9 100644 --- a/gui/src/renderer/components/Settings.tsx +++ b/gui/src/renderer/components/Settings.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { colors, links } from '../../config.json'; import { hasExpired, formatRemainingTime } from '../../shared/account-expiry'; import { messages } from '../../shared/gettext'; +import History from '../lib/history'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import * as Cell from './cell'; import { Layout } from './Layout'; @@ -42,9 +43,17 @@ export interface IProps { onViewPreferences: () => void; onViewAdvancedSettings: () => void; onExternalLink: (url: string) => void; + updateAccountData: () => void; + history: History; } export default class Settings extends React.Component<IProps> { + public componentDidMount() { + if (this.props.history.action === 'PUSH') { + this.props.updateAccountData(); + } + } + public render() { const showLargeTitle = this.props.loginState.type !== 'ok'; diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx index 985161c838..8751107c74 100644 --- a/gui/src/renderer/containers/AccountPage.tsx +++ b/gui/src/renderer/containers/AccountPage.tsx @@ -22,6 +22,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp props.history.pop(); }, onBuyMore: () => props.app.openLinkWithAuth(links.purchase), + updateAccountData: () => props.app.updateAccountData(), }; }; diff --git a/gui/src/renderer/containers/SettingsPage.tsx b/gui/src/renderer/containers/SettingsPage.tsx index 97ff917e7e..57ded8cd24 100644 --- a/gui/src/renderer/containers/SettingsPage.tsx +++ b/gui/src/renderer/containers/SettingsPage.tsx @@ -26,6 +26,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp onViewPreferences: () => props.history.push('/settings/preferences'), onViewAdvancedSettings: () => props.history.push('/settings/advanced'), onExternalLink: (url: string) => props.app.openUrl(url), + updateAccountData: () => props.app.updateAccountData(), }; }; diff --git a/gui/src/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts index 7ca382efb2..81c45fec07 100644 --- a/gui/src/shared/account-expiry.ts +++ b/gui/src/shared/account-expiry.ts @@ -5,10 +5,10 @@ export function hasExpired(expiry: DateType): boolean { return new Date(expiry).getTime() < Date.now(); } -export function closeToExpiry(expiry: DateType): boolean { +export function closeToExpiry(expiry: DateType, days = 3): boolean { return ( !hasExpired(expiry) && - new Date(expiry) <= dateByAddingComponent(new Date(), DateComponent.day, 3) + new Date(expiry) <= dateByAddingComponent(new Date(), DateComponent.day, days) ); } diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 5789bcc149..e2b3ebbed3 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -164,6 +164,7 @@ export const ipcSchema = { logout: invoke<void, void>(), getWwwAuthToken: invoke<void, string>(), submitVoucher: invoke<string, VoucherResponse>(), + updateData: send<void>(), }, accountHistory: { '': notifyRenderer<AccountToken | undefined>(), diff --git a/gui/test/account-data-cache.spec.ts b/gui/test/account-data-cache.spec.ts index c482e16a5d..6ed10c6908 100644 --- a/gui/test/account-data-cache.spec.ts +++ b/gui/test/account-data-cache.spec.ts @@ -135,41 +135,6 @@ describe('IAccountData cache', () => { }); }); - it('should refetch if account has expired', async () => { - const expiredSpy = spy(); - const nonExpiredSpy = spy(); - - const update = new Promise<void>((resolve, reject) => { - let firstAttempt = true; - const fetch = () => { - if (firstAttempt) { - expiredSpy(); - firstAttempt = false; - setTimeout(() => clock.tick(60_000), 0); - return Promise.resolve({ - expiry: new Date('1969-01-01').toISOString(), - }); - } else { - nonExpiredSpy(); - resolve(); - return Promise.resolve(dummyAccountData); - } - }; - - const cache = new AccountDataCache(fetch, () => {}); - - cache.fetch(dummyAccountToken, { - onFinish: () => {}, - onError: (_error: Error) => reject(), - }); - }); - - return expect(update).to.eventually.be.fulfilled.then(() => { - expect(expiredSpy).to.have.been.called.once; - expect(nonExpiredSpy).to.have.been.called.once; - }); - }); - it('should clear scheduled retry if another fetch is performed', async () => { const firstError = spy(); const secondSuccess = spy(); @@ -261,4 +226,92 @@ describe('IAccountData cache', () => { return expect(update).to.eventually.be.fulfilled; }); + + it('should invalidate after 60 seconds', async () => { + const fetchSpy = spy(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const update = new Promise<void>((resolve, reject) => { + const cache = new AccountDataCache( + (_accountToken) => { + fetchSpy(); + return Promise.resolve({ expiry }); + }, + () => {}, + ); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + clock.tick(59_000); + // Timeout to let asynchronous tasks finish + await new Promise((resolve) => setTimeout(resolve)); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + clock.tick(1_000); + // Timeout to let asynchronous tasks finish + await new Promise((resolve) => setTimeout(resolve)); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + resolve(); + }, + onError: (_error: Error) => reject(), + }); + }, + onError: (_error: Error) => reject(), + }); + }, + onError: (_error: Error) => reject(), + }); + }); + + return expect(update).to.eventually.be.fulfilled.then(() => { + expect(fetchSpy).to.have.been.called.twice; + }); + }); + + it('should invalidate after 10 seconds when epired', async () => { + const fetchSpy = spy(); + const expiry = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const update = new Promise<void>((resolve, reject) => { + const cache = new AccountDataCache( + (_accountToken) => { + fetchSpy(); + return Promise.resolve({ expiry }); + }, + () => {}, + ); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + clock.tick(9_000); + // Timeout to let asynchronous tasks finish + await new Promise((resolve) => setTimeout(resolve)); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + clock.tick(1_000); + // Timeout to let asynchronous tasks finish + await new Promise((resolve) => setTimeout(resolve)); + + cache.fetch(dummyAccountToken, { + onFinish: async () => { + resolve(); + }, + onError: (_error: Error) => reject(), + }); + }, + onError: (_error: Error) => reject(), + }); + }, + onError: (_error: Error) => reject(), + }); + }); + + return expect(update).to.eventually.be.fulfilled.then(() => { + expect(fetchSpy).to.have.been.called.twice; + }); + }); }); |
