From 9aff86f02be62823c4f41c2b31152c11b7bf9476 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:48:00 -0700 Subject: [PATCH] Simplify online/offline status management --- .storybook/preview.tsx | 1 + ts/background.ts | 163 ++++++----------- ts/badges/badgeImageFileDownloader.ts | 2 +- ts/hooks/useIsOnline.ts | 21 ++- ts/jobs/helpers/commonShouldJobContinue.ts | 2 +- ts/jobs/reportSpamJobQueue.ts | 2 +- ts/services/areWeASubscriber.ts | 4 +- ts/services/networkObserver.ts | 48 +++-- ts/services/senderCertificate.ts | 19 +- ts/state/ducks/calling.ts | 7 +- ts/state/ducks/network.ts | 48 ++--- ts/state/selectors/network.ts | 17 +- .../services/areWeASubscriber_test.ts | 31 +++- .../services/senderCertificate_test.ts | 19 +- ts/test-electron/util/waitForOnline_test.ts | 91 ++++++---- ts/textsecure/SocketManager.ts | 171 ++++++++++++------ ts/textsecure/UpdateKeysListener.ts | 6 +- ts/textsecure/WebAPI.ts | 40 ++-- ts/textsecure/WebsocketResources.ts | 16 +- ts/types/Stickers.ts | 7 +- ts/util/BackOff.ts | 18 +- ts/util/waitForOnline.ts | 34 +++- 22 files changed, 432 insertions(+), 335 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ad037f6177..978da796fb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -80,6 +80,7 @@ const noop = () => {}; window.Whisper = window.Whisper || {}; window.Whisper.events = { on: noop, + off: noop, }; window.SignalContext = { diff --git a/ts/background.ts b/ts/background.ts index 8f02d1352b..a0326c48c1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -478,8 +478,7 @@ export async function startApp(): Promise { senderCertificateService.initialize({ server, - navigator, - onlineEventTarget: window, + events: window.Whisper.events, storage: window.storage, }); @@ -1334,8 +1333,8 @@ export async function startApp(): Promise { } log.warn('background: remote expiration detected, disabling reconnects'); + drop(server?.onRemoteExpiration()); remotelyExpired = true; - onOffline(); }); async function runStorageService() { @@ -1417,12 +1416,18 @@ export async function startApp(): Promise { log.info('Expiration start timestamp cleanup: complete'); log.info('listening for registration events'); - window.Whisper.events.on('registration_done', async () => { + window.Whisper.events.on('registration_done', () => { log.info('handling registration event'); strictAssert(server !== undefined, 'WebAPI not ready'); - await server.authenticate( - window.textsecure.storage.user.getWebAPICredentials() + + // Once this resolves it will trigger `online` event and cause + // `connect()`, but with `firstRun` set to `false`. Thus it is important + // not to await it and let execution fall through. + drop( + server.authenticate( + window.textsecure.storage.user.getWebAPICredentials() + ) ); // Cancel throttled calls to refreshRemoteConfig since our auth changed. @@ -1525,52 +1530,20 @@ export async function startApp(): Promise { return syncRequest; }; - let disconnectTimer: Timers.Timeout | undefined; - let reconnectTimer: Timers.Timeout | undefined; - function onOffline() { - log.info('offline'); + function onNavigatorOffline() { + log.info('background: navigator offline'); - window.removeEventListener('offline', onOffline); - window.addEventListener('online', onOnline); - - // We've received logs from Linux where we get an 'offline' event, then 30ms later - // we get an online event. This waits a bit after getting an 'offline' event - // before disconnecting the socket manually. - disconnectTimer = Timers.setTimeout(disconnect, 1000); - - if (challengeHandler) { - void challengeHandler.onOffline(); - } + drop(server?.onNavigatorOffline()); } - function onOnline() { - if (remotelyExpired) { - return; - } - - log.info('online'); - - window.removeEventListener('online', onOnline); - window.addEventListener('offline', onOffline); - - if (disconnectTimer && isSocketOnline()) { - log.warn('Already online. Had a blip in online/offline status.'); - Timers.clearTimeout(disconnectTimer); - disconnectTimer = undefined; - - if (challengeHandler) { - drop(challengeHandler.onOnline()); - } - return; - } - if (disconnectTimer) { - Timers.clearTimeout(disconnectTimer); - disconnectTimer = undefined; - } - - void connect(); + function onNavigatorOnline() { + log.info('background: navigator online'); + drop(server?.onNavigatorOnline()); } + window.addEventListener('online', onNavigatorOnline); + window.addEventListener('offline', onNavigatorOffline); + function isSocketOnline() { const socketStatus = window.getSocketStatus(); return ( @@ -1579,34 +1552,47 @@ export async function startApp(): Promise { ); } - async function disconnect() { - log.info('disconnect'); - - // Clear timer, since we're only called when the timer is expired - disconnectTimer = undefined; - - void AttachmentDownloads.stop(); - if (server !== undefined) { - strictAssert( - messageReceiver !== undefined, - 'WebAPI should be initialized together with MessageReceiver' - ); - await server.onOffline(); - await messageReceiver.drain(); + window.Whisper.events.on('online', () => { + log.info('background: online'); + if (!remotelyExpired) { + drop(connect()); } - } + }); + + window.Whisper.events.on('offline', () => { + log.info('background: offline'); + + drop(challengeHandler?.onOffline()); + drop(AttachmentDownloads.stop()); + drop(messageReceiver?.drain()); + + if (connectCount === 0) { + log.info('background: offline, never connected, showing inbox'); + + drop(onEmpty()); // this ensures that the loading screen is dismissed + + // Switch to inbox view even if contact sync is still running + if (window.reduxStore.getState().app.appView === AppViewType.Installer) { + log.info('background: offline, opening inbox'); + window.reduxActions.app.openInbox(); + } + } + }); let connectCount = 0; let connecting = false; let remotelyExpired = false; async function connect(firstRun?: boolean) { if (connecting) { - log.warn('connect already running', { connectCount }); + log.warn('background: connect already running', { + connectCount, + firstRun, + }); return; } if (remotelyExpired) { - log.warn('remotely expired, not reconnecting'); + log.warn('background: remotely expired, not reconnecting'); return; } @@ -1618,40 +1604,13 @@ export async function startApp(): Promise { // Reset the flag and update it below if needed setIsInitialSync(false); - log.info('connect', { firstRun, connectCount }); - - if (reconnectTimer) { - Timers.clearTimeout(reconnectTimer); - reconnectTimer = undefined; - } - - // Bootstrap our online/offline detection, only the first time we connect - if (connectCount === 0 && navigator.onLine) { - window.addEventListener('offline', onOffline); - } - if (connectCount === 0 && !navigator.onLine) { - log.warn( - 'Starting up offline; will connect when we have network access' - ); - window.addEventListener('online', onOnline); - void onEmpty(); // this ensures that the loading screen is dismissed - - // Switch to inbox view even if contact sync is still running - if ( - window.reduxStore.getState().app.appView === AppViewType.Installer - ) { - log.info('firstRun: offline, opening inbox'); - window.reduxActions.app.openInbox(); - } else { - log.info('firstRun: offline, not opening inbox'); - } - return; - } - if (!Registration.everDone()) { + log.info('background: registration not done, not connecting'); return; } + log.info('background: connect', { firstRun, connectCount }); + // Update our profile key in the conversation if we just got linked. const profileKey = await ourProfileKeyService.get(); if (firstRun && profileKey) { @@ -1710,14 +1669,11 @@ export async function startApp(): Promise { messageReceiver.reset(); server.registerRequestHandler(messageReceiver); - // If coming here after `offline` event - connect again. - if (!remotelyExpired) { - await server.onOnline(); - } - - void AttachmentDownloads.start({ - logger: log, - }); + drop( + AttachmentDownloads.start({ + logger: log, + }) + ); if (connectCount === 1) { Stickers.downloadQueuedPacks(); @@ -2053,7 +2009,8 @@ export async function startApp(): Promise { } log.info('manualConnect: calling connect()'); - void connect(); + enqueueReconnectToWebSocket(); + drop(connect()); } async function onConfiguration(ev: ConfigurationEvent): Promise { diff --git a/ts/badges/badgeImageFileDownloader.ts b/ts/badges/badgeImageFileDownloader.ts index ef4927e37b..19ba3db73e 100644 --- a/ts/badges/badgeImageFileDownloader.ts +++ b/ts/badges/badgeImageFileDownloader.ts @@ -86,7 +86,7 @@ function getUrlsToDownload(): Array { } async function downloadBadgeImageFile(url: string): Promise { - await waitForOnline(navigator, window, { timeout: 1 * MINUTE }); + await waitForOnline({ timeout: 1 * MINUTE }); const { server } = window.textsecure; if (!server) { diff --git a/ts/hooks/useIsOnline.ts b/ts/hooks/useIsOnline.ts index 68eb9d1dd7..964d047169 100644 --- a/ts/hooks/useIsOnline.ts +++ b/ts/hooks/useIsOnline.ts @@ -3,22 +3,31 @@ import { useEffect, useState } from 'react'; +function getOnlineStatus(): boolean { + if (window.textsecure) { + return window.textsecure.server?.isOnline() ?? true; + } + + // Only for storybook + return navigator.onLine; +} + export function useIsOnline(): boolean { - const [isOnline, setIsOnline] = useState(navigator.onLine); + const [isOnline, setIsOnline] = useState(getOnlineStatus()); useEffect(() => { const update = () => { - setIsOnline(navigator.onLine); + setIsOnline(getOnlineStatus()); }; update(); - window.addEventListener('offline', update); - window.addEventListener('online', update); + window.Whisper.events.on('online', update); + window.Whisper.events.on('offline', update); return () => { - window.removeEventListener('offline', update); - window.removeEventListener('online', update); + window.Whisper.events.off('online', update); + window.Whisper.events.off('offline', update); }; }, []); diff --git a/ts/jobs/helpers/commonShouldJobContinue.ts b/ts/jobs/helpers/commonShouldJobContinue.ts index 0ce8eac5dc..59d63e163a 100644 --- a/ts/jobs/helpers/commonShouldJobContinue.ts +++ b/ts/jobs/helpers/commonShouldJobContinue.ts @@ -24,7 +24,7 @@ export async function commonShouldJobContinue({ } try { - await waitForOnline(window.navigator, window, { timeout: timeRemaining }); + await waitForOnline({ timeout: timeRemaining }); } catch (err: unknown) { log.info("didn't come online in time, giving up"); return false; diff --git a/ts/jobs/reportSpamJobQueue.ts b/ts/jobs/reportSpamJobQueue.ts index 6ab92b41a1..c189400b03 100644 --- a/ts/jobs/reportSpamJobQueue.ts +++ b/ts/jobs/reportSpamJobQueue.ts @@ -62,7 +62,7 @@ export class ReportSpamJobQueue extends JobQueue { return undefined; } - await waitForOnline(window.navigator, window); + await waitForOnline(); const { server } = this; strictAssert(server !== undefined, 'ReportSpamJobQueue not initialized'); diff --git a/ts/services/areWeASubscriber.ts b/ts/services/areWeASubscriber.ts index 1f0e8c36db..e2daba69d2 100644 --- a/ts/services/areWeASubscriber.ts +++ b/ts/services/areWeASubscriber.ts @@ -12,7 +12,7 @@ export class AreWeASubscriberService { update( storage: Pick, - server: Pick + server: Pick ): void { this.queue.add(async () => { await new Promise(resolve => storage.onready(resolve)); @@ -23,7 +23,7 @@ export class AreWeASubscriberService { return; } - await waitForOnline(navigator, window); + await waitForOnline({ server }); await storage.put( 'areWeASubscriber', diff --git a/ts/services/networkObserver.ts b/ts/services/networkObserver.ts index 0871d444b2..4166deed07 100644 --- a/ts/services/networkObserver.ts +++ b/ts/services/networkObserver.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { - CheckNetworkStatusPayloadType, + SetNetworkStatusPayloadType, NetworkActionType, } from '../state/ducks/network'; import { getSocketStatus } from '../shims/socketStatus'; @@ -17,9 +17,16 @@ const OUTAGE_CHECK_INTERVAL = 60 * SECOND; const OUTAGE_HEALTY_ADDR = '127.0.0.1'; const OUTAGE_NO_SERVICE_ADDR = '127.0.0.2'; +enum OnlineStatus { + Online = 'Online', + MaybeOffline = 'MaybeOffline', + Offline = 'Offline', +} + +const OFFLINE_DELAY = 5 * SECOND; + type NetworkActions = { - checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType; - closeConnectingGracePeriod: () => NetworkActionType; + setNetworkStatus: (x: SetNetworkStatusPayloadType) => NetworkActionType; setOutage: (isOutage: boolean) => NetworkActionType; }; @@ -28,11 +35,13 @@ export function initializeNetworkObserver( ): void { log.info('Initializing network observer'); + let onlineStatus = OnlineStatus.Online; + const refresh = () => { const socketStatus = getSocketStatus(); - networkActions.checkNetworkStatus({ - isOnline: navigator.onLine, + networkActions.setNetworkStatus({ + isOnline: onlineStatus !== OnlineStatus.Offline, socketStatus, }); @@ -89,12 +98,27 @@ export function initializeNetworkObserver( networkActions.setOutage(false); }; - window.Whisper.events.on('socketStatusChange', refresh); - window.Whisper.events.on('socketConnectError', onPotentialOutage); + let offlineTimer: NodeJS.Timeout | undefined; - window.addEventListener('online', refresh); - window.addEventListener('offline', refresh); - window.setTimeout(() => { - networkActions.closeConnectingGracePeriod(); - }, 5 * SECOND); + window.Whisper.events.on('socketStatusChange', refresh); + window.Whisper.events.on('online', () => { + onlineStatus = OnlineStatus.Online; + if (offlineTimer) { + clearTimeout(offlineTimer); + offlineTimer = undefined; + } + refresh(); + }); + window.Whisper.events.on('offline', () => { + if (onlineStatus !== OnlineStatus.Online) { + return; + } + + onlineStatus = OnlineStatus.MaybeOffline; + offlineTimer = setTimeout(() => { + onlineStatus = OnlineStatus.Offline; + refresh(); + onPotentialOutage(); + }, OFFLINE_DELAY); + }); } diff --git a/ts/services/senderCertificate.ts b/ts/services/senderCertificate.ts index 5c1b9e0e5a..395118a347 100644 --- a/ts/services/senderCertificate.ts +++ b/ts/services/senderCertificate.ts @@ -34,28 +34,23 @@ export class SenderCertificateService { Promise > = new Map(); - private navigator?: { onLine: boolean }; - - private onlineEventTarget?: EventTarget; + private events?: Pick; private storage?: StorageInterface; initialize({ server, - navigator, - onlineEventTarget, + events, storage, }: { server: WebAPIType; - navigator: Readonly<{ onLine: boolean }>; - onlineEventTarget: EventTarget; + events?: Pick; storage: StorageInterface; }): void { log.info('Sender certificate service initialized'); this.server = server; - this.navigator = navigator; - this.onlineEventTarget = onlineEventTarget; + this.events = events; this.storage = storage; } @@ -150,9 +145,9 @@ export class SenderCertificateService { private async fetchAndSaveCertificate( mode: SenderCertificateMode ): Promise { - const { storage, navigator, onlineEventTarget } = this; + const { storage, server, events } = this; assertDev( - storage && navigator && onlineEventTarget, + storage && server && events, 'Sender certificate service method was called before it was initialized' ); @@ -162,7 +157,7 @@ export class SenderCertificateService { )} certificate` ); - await waitForOnline(navigator, onlineEventTarget); + await waitForOnline({ server, events }); let certificateString: string; try { diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 5afbe7f835..1f97d43d2d 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -477,7 +477,12 @@ const doGroupCallPeek = ({ // If we peek right after receiving the message, we may get outdated information. // This is most noticeable when someone leaves. We add a delay and then make sure // to only be peeking once. - await Promise.all([sleep(1000), waitForOnline(navigator, window)]); + const { server } = window.textsecure; + if (!server) { + log.error('doGroupCallPeek: no textsecure server'); + return; + } + await Promise.all([sleep(1000), waitForOnline()]); let peekInfo = null; try { diff --git a/ts/state/ducks/network.ts b/ts/state/ducks/network.ts index 6c25845bc6..ddd347651f 100644 --- a/ts/state/ducks/network.ts +++ b/ts/state/ducks/network.ts @@ -14,30 +14,24 @@ export type NetworkStateType = ReadonlyDeep<{ isOnline: boolean; isOutage: boolean; socketStatus: SocketStatus; - withinConnectingGracePeriod: boolean; challengeStatus: 'required' | 'pending' | 'idle'; }>; // Actions -const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS'; -const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD'; +const SET_NETWORK_STATUS = 'network/SET_NETWORK_STATUS'; const RELINK_DEVICE = 'network/RELINK_DEVICE'; const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS'; const SET_OUTAGE = 'network/SET_OUTAGE'; -export type CheckNetworkStatusPayloadType = ReadonlyDeep<{ +export type SetNetworkStatusPayloadType = ReadonlyDeep<{ isOnline: boolean; socketStatus: SocketStatus; }>; -type CheckNetworkStatusAction = ReadonlyDeep<{ - type: 'network/CHECK_NETWORK_STATUS'; - payload: CheckNetworkStatusPayloadType; -}>; - -type CloseConnectingGracePeriodActionType = ReadonlyDeep<{ - type: 'network/CLOSE_CONNECTING_GRACE_PERIOD'; +type SetNetworkStatusAction = ReadonlyDeep<{ + type: 'network/SET_NETWORK_STATUS'; + payload: SetNetworkStatusPayloadType; }>; type RelinkDeviceActionType = ReadonlyDeep<{ @@ -59,8 +53,7 @@ type SetOutageActionType = ReadonlyDeep<{ }>; export type NetworkActionType = ReadonlyDeep< - | CheckNetworkStatusAction - | CloseConnectingGracePeriodActionType + | SetNetworkStatusAction | RelinkDeviceActionType | SetChallengeStatusActionType | SetOutageActionType @@ -68,21 +61,15 @@ export type NetworkActionType = ReadonlyDeep< // Action Creators -function checkNetworkStatus( - payload: CheckNetworkStatusPayloadType -): CheckNetworkStatusAction { +function setNetworkStatus( + payload: SetNetworkStatusPayloadType +): SetNetworkStatusAction { return { - type: CHECK_NETWORK_STATUS, + type: SET_NETWORK_STATUS, payload, }; } -function closeConnectingGracePeriod(): CloseConnectingGracePeriodActionType { - return { - type: CLOSE_CONNECTING_GRACE_PERIOD, - }; -} - function relinkDevice(): RelinkDeviceActionType { trigger('setupAsNewDevice'); @@ -108,8 +95,7 @@ function setOutage(isOutage: boolean): SetOutageActionType { } export const actions = { - checkNetworkStatus, - closeConnectingGracePeriod, + setNetworkStatus, relinkDevice, setChallengeStatus, setOutage, @@ -123,10 +109,9 @@ export const useNetworkActions = (): BoundActionCreatorsMapObject< export function getEmptyState(): NetworkStateType { return { - isOnline: navigator.onLine, + isOnline: true, isOutage: false, socketStatus: SocketStatus.OPEN, - withinConnectingGracePeriod: true, challengeStatus: 'idle', }; } @@ -135,7 +120,7 @@ export function reducer( state: Readonly = getEmptyState(), action: Readonly ): NetworkStateType { - if (action.type === CHECK_NETWORK_STATUS) { + if (action.type === SET_NETWORK_STATUS) { const { isOnline, socketStatus } = action.payload; // This action is dispatched frequently. We avoid allocating a new object if nothing @@ -146,13 +131,6 @@ export function reducer( }); } - if (action.type === CLOSE_CONNECTING_GRACE_PERIOD) { - return { - ...state, - withinConnectingGracePeriod: false, - }; - } - if (action.type === SET_CHALLENGE_STATUS) { return { ...state, diff --git a/ts/state/selectors/network.ts b/ts/state/selectors/network.ts index 8ffa255cd6..43fa59f928 100644 --- a/ts/state/selectors/network.ts +++ b/ts/state/selectors/network.ts @@ -6,7 +6,6 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; import type { NetworkStateType } from '../ducks/network'; import { isDone } from '../../util/registration'; -import { SocketStatus } from '../../types/SocketStatus'; const getNetwork = (state: StateType): NetworkStateType => state.network; @@ -29,21 +28,9 @@ export const hasNetworkDialog = createSelector( getNetwork, isDone, ( - { - isOnline, - isOutage, - socketStatus, - withinConnectingGracePeriod, - }: NetworkStateType, + { isOnline, isOutage }: NetworkStateType, isRegistrationDone: boolean - ): boolean => - isRegistrationDone && - (!isOnline || - isOutage || - (socketStatus === SocketStatus.CONNECTING && - !withinConnectingGracePeriod) || - socketStatus === SocketStatus.CLOSED || - socketStatus === SocketStatus.CLOSING) + ): boolean => isRegistrationDone && (!isOnline || isOutage) ); export const getChallengeStatus = createSelector( diff --git a/ts/test-electron/services/areWeASubscriber_test.ts b/ts/test-electron/services/areWeASubscriber_test.ts index 29b782c12b..fcbb260f4e 100644 --- a/ts/test-electron/services/areWeASubscriber_test.ts +++ b/ts/test-electron/services/areWeASubscriber_test.ts @@ -20,11 +20,13 @@ describe('"are we a subscriber?" service', () => { sandbox = sinon.createSandbox(); service = new AreWeASubscriberService(); - sandbox.stub(navigator, 'onLine').get(() => true); }); it("stores false if there's no local subscriber ID", done => { - const fakeServer = { getHasSubscription: sandbox.stub() }; + const fakeServer = { + getHasSubscription: sandbox.stub(), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, get: () => undefined, @@ -39,7 +41,10 @@ describe('"are we a subscriber?" service', () => { }); it("doesn't make a network request if there's no local subscriber ID", done => { - const fakeServer = { getHasSubscription: sandbox.stub() }; + const fakeServer = { + getHasSubscription: sandbox.stub(), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, get: () => undefined, @@ -53,7 +58,10 @@ describe('"are we a subscriber?" service', () => { }); it('requests the subscriber ID from the server', done => { - const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeServer = { + getHasSubscription: sandbox.stub().resolves(false), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, put: sandbox @@ -72,7 +80,10 @@ describe('"are we a subscriber?" service', () => { }); it("stores when we're not a subscriber", done => { - const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeServer = { + getHasSubscription: sandbox.stub().resolves(false), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, put: sandbox.stub().callsFake((key, value) => { @@ -86,7 +97,10 @@ describe('"are we a subscriber?" service', () => { }); it("stores when we're a subscriber", done => { - const fakeServer = { getHasSubscription: sandbox.stub().resolves(true) }; + const fakeServer = { + getHasSubscription: sandbox.stub().resolves(true), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, put: sandbox.stub().callsFake((key, value) => { @@ -103,7 +117,10 @@ describe('"are we a subscriber?" service', () => { const allDone = explodePromise(); let putCallCount = 0; - const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeServer = { + getHasSubscription: sandbox.stub().resolves(false), + isOnline: () => true, + }; const fakeStorage = { ...fakeStorageDefaults, put: sandbox.stub().callsFake(() => { diff --git a/ts/test-electron/services/senderCertificate_test.ts b/ts/test-electron/services/senderCertificate_test.ts index 7258d3fc18..742b6a43a3 100644 --- a/ts/test-electron/services/senderCertificate_test.ts +++ b/ts/test-electron/services/senderCertificate_test.ts @@ -25,16 +25,14 @@ describe('SenderCertificateService', () => { let fakeValidEncodedCertificate: Uint8Array; let fakeValidCertificateExpiry: number; let fakeServer: any; - let fakeNavigator: { onLine: boolean }; - let fakeWindow: EventTarget; + let fakeEvents: Pick; let fakeStorage: any; function initializeTestService(): SenderCertificateService { const result = new SenderCertificateService(); result.initialize({ server: fakeServer, - navigator: fakeNavigator, - onlineEventTarget: fakeWindow, + events: fakeEvents, storage: fakeStorage, }); return result; @@ -51,18 +49,16 @@ describe('SenderCertificateService', () => { SenderCertificate.encode(fakeValidCertificate).finish(); fakeServer = { + isOnline: () => true, getSenderCertificate: sinon.stub().resolves({ certificate: Bytes.toBase64(fakeValidEncodedCertificate), }), }; - fakeNavigator = { onLine: true }; - - fakeWindow = { - addEventListener: sinon.stub(), - dispatchEvent: sinon.stub(), - removeEventListener: sinon.stub(), - }; + fakeEvents = { + on: sinon.stub(), + off: sinon.stub(), + } as unknown as typeof fakeEvents; fakeStorage = { get: sinon.stub(), @@ -221,6 +217,7 @@ describe('SenderCertificateService', () => { let count = 0; fakeServer = { + isOnline: () => true, getSenderCertificate: sinon.spy(async () => { await new Promise(resolve => setTimeout(resolve, 500)); diff --git a/ts/test-electron/util/waitForOnline_test.ts b/ts/test-electron/util/waitForOnline_test.ts index 4885e1736f..7b14126409 100644 --- a/ts/test-electron/util/waitForOnline_test.ts +++ b/ts/test-electron/util/waitForOnline_test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { EventEmitter } from 'events'; import * as sinon from 'sinon'; import { waitForOnline } from '../../util/waitForOnline'; @@ -17,28 +18,28 @@ describe('waitForOnline', () => { sandbox.restore(); }); - function getFakeWindow(): EventTarget { - const result = new EventTarget(); - sinon.stub(result, 'addEventListener'); - sinon.stub(result, 'removeEventListener'); + function getFakeEmitter(): EventEmitter { + const result = new EventEmitter(); + sinon.stub(result, 'on'); + sinon.stub(result, 'off'); return result; } it("resolves immediately if you're online", async () => { - const fakeNavigator = { onLine: true }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => true }; + const fakeEvents = getFakeEmitter(); - await waitForOnline(fakeNavigator, fakeWindow); + await waitForOnline({ server: fakeServer, events: fakeEvents }); - sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); - sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub); }); it("if you're offline, resolves as soon as you're online (and cleans up listeners)", async () => { - const fakeNavigator = { onLine: false }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => false }; + const fakeEvents = getFakeEmitter(); - (fakeWindow.addEventListener as sinon.SinonStub) + (fakeEvents.on as sinon.SinonStub) .withArgs('online') .callsFake((_eventName: string, callback: () => void) => { setTimeout(callback, 0); @@ -46,7 +47,7 @@ describe('waitForOnline', () => { let done = false; const promise = (async () => { - await waitForOnline(fakeNavigator, fakeWindow); + await waitForOnline({ server: fakeServer, events: fakeEvents }); done = true; })(); @@ -55,37 +56,41 @@ describe('waitForOnline', () => { await promise; assert.isTrue(done); - sinon.assert.calledOnce(fakeWindow.addEventListener as sinon.SinonStub); - sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub); + sinon.assert.calledOnce(fakeEvents.on as sinon.SinonStub); + sinon.assert.calledOnce(fakeEvents.off as sinon.SinonStub); }); it("resolves immediately if you're online when passed a timeout", async () => { - const fakeNavigator = { onLine: true }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => true }; + const fakeEvents = getFakeEmitter(); - await waitForOnline(fakeNavigator, fakeWindow, { timeout: 1234 }); + await waitForOnline({ + server: fakeServer, + events: fakeEvents, + timeout: 1234, + }); - sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); - sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub); }); it("resolves immediately if you're online even if passed a timeout of 0", async () => { - const fakeNavigator = { onLine: true }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => true }; + const fakeEvents = getFakeEmitter(); - await waitForOnline(fakeNavigator, fakeWindow, { timeout: 0 }); + await waitForOnline({ server: fakeServer, events: fakeEvents, timeout: 0 }); - sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); - sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub); + sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub); }); it("if you're offline, resolves as soon as you're online if it happens before the timeout", async () => { const clock = sandbox.useFakeTimers(); - const fakeNavigator = { onLine: false }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => false }; + const fakeEvents = getFakeEmitter(); - (fakeWindow.addEventListener as sinon.SinonStub) + (fakeEvents.on as sinon.SinonStub) .withArgs('online') .callsFake((_eventName: string, callback: () => void) => { setTimeout(callback, 1000); @@ -93,7 +98,11 @@ describe('waitForOnline', () => { let done = false; void (async () => { - await waitForOnline(fakeNavigator, fakeWindow, { timeout: 9999 }); + await waitForOnline({ + server: fakeServer, + events: fakeEvents, + timeout: 9999, + }); done = true; })(); @@ -108,16 +117,18 @@ describe('waitForOnline', () => { it('rejects if too much time has passed, and cleans up listeners', async () => { const clock = sandbox.useFakeTimers(); - const fakeNavigator = { onLine: false }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => false }; + const fakeEvents = getFakeEmitter(); - (fakeWindow.addEventListener as sinon.SinonStub) + (fakeEvents.on as sinon.SinonStub) .withArgs('online') .callsFake((_eventName: string, callback: () => void) => { setTimeout(callback, 9999); }); - const promise = waitForOnline(fakeNavigator, fakeWindow, { + const promise = waitForOnline({ + server: fakeServer, + events: fakeEvents, timeout: 100, }); @@ -125,20 +136,24 @@ describe('waitForOnline', () => { await assert.isRejected(promise); - sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub); + sinon.assert.calledOnce(fakeEvents.off as sinon.SinonStub); }); it('rejects if offline and passed a timeout of 0', async () => { - const fakeNavigator = { onLine: false }; - const fakeWindow = getFakeWindow(); + const fakeServer = { isOnline: () => false }; + const fakeEvents = getFakeEmitter(); - (fakeWindow.addEventListener as sinon.SinonStub) + (fakeEvents.on as sinon.SinonStub) .withArgs('online') .callsFake((_eventName: string, callback: () => void) => { setTimeout(callback, 9999); }); - const promise = waitForOnline(fakeNavigator, fakeWindow, { timeout: 0 }); + const promise = waitForOnline({ + server: fakeServer, + events: fakeEvents, + timeout: 100, + }); await assert.isRejected(promise); }); diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index a9e3ce1611..d826b5f8c2 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -10,9 +10,14 @@ import EventListener from 'events'; import { AbortableProcess } from '../util/AbortableProcess'; import { strictAssert } from '../util/assert'; -import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff'; +import { + BackOff, + FIBONACCI_TIMEOUTS, + EXTENDED_FIBONACCI_TIMEOUTS, +} from '../util/BackOff'; import * as durations from '../util/durations'; import { sleep } from '../util/sleep'; +import { drop } from '../util/drop'; import { createProxyAgent } from '../util/createProxyAgent'; import { SocketStatus } from '../types/SocketStatus'; import * as Errors from '../types/errors'; @@ -38,6 +43,7 @@ const FIVE_MINUTES = 5 * durations.MINUTE; const JITTER = 5 * durations.SECOND; +const OFFLINE_KEEPALIVE_TIMEOUT_MS = 5 * durations.SECOND; export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated'; export const AUTHENTICATED_CHANNEL_NAME = 'authenticated'; @@ -86,10 +92,16 @@ export class SocketManager extends EventListener { private incomingRequestQueue = new Array(); - private isOffline = false; + private isNavigatorOffline = false; + + private privIsOnline: boolean | undefined; + + private isRemotelyExpired = false; private hasStoriesDisabled: boolean; + private reconnectController: AbortController | undefined; + constructor(private readonly options: SocketManagerOptions) { super(); @@ -107,8 +119,8 @@ export class SocketManager extends EventListener { // Update WebAPICredentials and reconnect authenticated resource if // credentials changed public async authenticate(credentials: WebAPICredentials): Promise { - if (this.isOffline) { - throw new HTTPError('SocketManager offline', { + if (this.isRemotelyExpired) { + throw new HTTPError('SocketManager remotely expired', { code: 0, headers: {}, stack: new Error().stack, @@ -169,6 +181,11 @@ export class SocketManager extends EventListener { this.authenticated = process; const reconnect = async (): Promise => { + if (this.isRemotelyExpired) { + log.info('SocketManager: remotely expired, not reconnecting'); + return; + } + const timeout = this.backOff.getAndIncrement(); log.info( @@ -176,14 +193,22 @@ export class SocketManager extends EventListener { `after ${timeout}ms` ); - await sleep(timeout); - if (this.isOffline) { - log.info('SocketManager: cancelled reconnect because we are offline'); + const reconnectController = new AbortController(); + this.reconnectController = reconnectController; + + try { + await sleep(timeout, reconnectController.signal); + } catch { + log.info('SocketManager: reconnect cancelled'); return; + } finally { + if (this.reconnectController === reconnectController) { + this.reconnectController = undefined; + } } if (this.authenticated) { - log.info('SocketManager: authenticated socket already reconnected'); + log.info('SocketManager: authenticated socket already connecting'); return; } @@ -230,12 +255,13 @@ export class SocketManager extends EventListener { return; } - if (code === -1) { - this.emit('connectError'); + if (code === -1 && this.privIsOnline !== false) { + this.privIsOnline = false; + this.emit('offline'); } } - void reconnect(); + drop(reconnect()); return; } @@ -267,7 +293,7 @@ export class SocketManager extends EventListener { return; } - void reconnect(); + drop(reconnect()); }); } @@ -287,6 +313,10 @@ export class SocketManager extends EventListener { public async getProvisioningResource( handler: IRequestHandler ): Promise { + if (this.isRemotelyExpired) { + throw new Error('Remotely expired, not connecting provisioning socket'); + } + return this.connectResource({ name: 'provisioning', path: '/v1/websocket/provisioning/', @@ -397,40 +427,6 @@ export class SocketManager extends EventListener { public async reconnect(): Promise { log.info('SocketManager.reconnect: starting...'); - this.onOffline(); - await this.onOnline(); - log.info('SocketManager.reconnect: complete.'); - } - - // Force keep-alive checks on WebSocketResources - public async check(): Promise { - if (this.isOffline) { - return; - } - - log.info('SocketManager.check'); - await Promise.all([ - SocketManager.checkResource(this.authenticated), - SocketManager.checkResource(this.unauthenticated), - ]); - } - - // Puts SocketManager into "online" state and reconnects the authenticated - // IWebSocketResource (if there are valid credentials) - public async onOnline(): Promise { - log.info('SocketManager.onOnline'); - this.isOffline = false; - - if (this.credentials) { - await this.authenticate(this.credentials); - } - } - - // Puts SocketManager into "offline" state and gracefully disconnects both - // unauthenticated and authenticated resources. - public onOffline(): void { - log.info('SocketManager.onOffline'); - this.isOffline = true; const { authenticated, unauthenticated } = this; if (authenticated) { @@ -441,6 +437,54 @@ export class SocketManager extends EventListener { unauthenticated.abort(); this.dropUnauthenticated(unauthenticated); } + + if (this.credentials) { + this.backOff.reset(); + + // Cancel old reconnect attempt + this.reconnectController?.abort(); + + // Start the new attempt + await this.authenticate(this.credentials); + } + + log.info('SocketManager.reconnect: complete.'); + } + + // Force keep-alive checks on WebSocketResources + public async check(): Promise { + log.info('SocketManager.check'); + await Promise.all([ + this.checkResource(this.authenticated), + this.checkResource(this.unauthenticated), + ]); + } + + public async onNavigatorOnline(): Promise { + log.info('SocketManager.onNavigatorOnline'); + this.isNavigatorOffline = false; + this.backOff.reset(FIBONACCI_TIMEOUTS); + + // Reconnect earlier if waiting + if (this.credentials !== undefined) { + this.reconnectController?.abort(); + await this.authenticate(this.credentials); + } + } + + public async onNavigatorOffline(): Promise { + log.info('SocketManager.onNavigatorOffline'); + this.isNavigatorOffline = true; + this.backOff.reset(EXTENDED_FIBONACCI_TIMEOUTS); + await this.check(); + } + + public async onRemoteExpiration(): Promise { + log.info('SocketManager.onRemoteExpiration'); + this.isRemotelyExpired = true; + + // Cancel reconnect attempt if any + this.reconnectController?.abort(); } public async logout(): Promise { @@ -453,6 +497,10 @@ export class SocketManager extends EventListener { this.credentials = undefined; } + public get isOnline(): boolean { + return this.privIsOnline !== false; + } + // // Private // @@ -464,6 +512,11 @@ export class SocketManager extends EventListener { this.status = status; this.emit('statusChange'); + + if (this.status === SocketStatus.OPEN && !this.privIsOnline) { + this.privIsOnline = true; + this.emit('online'); + } } private transportOption(): TransportOption { @@ -522,17 +575,19 @@ export class SocketManager extends EventListener { } private async getUnauthenticatedResource(): Promise { - if (this.isOffline) { - throw new HTTPError('SocketManager offline', { + if (this.unauthenticated) { + return this.unauthenticated.getResult(); + } + + if (this.isRemotelyExpired) { + throw new HTTPError('SocketManager remotely expired', { code: 0, headers: {}, stack: new Error().stack, }); } - if (this.unauthenticated) { - return this.unauthenticated.getResult(); - } + log.info('SocketManager: connecting unauthenticated socket'); const transportOption = this.transportOption(); log.info( @@ -631,7 +686,7 @@ export class SocketManager extends EventListener { }); } - private static async checkResource( + private async checkResource( process?: AbortableProcess ): Promise { if (!process) { @@ -639,7 +694,11 @@ export class SocketManager extends EventListener { } const resource = await process.getResult(); - resource.forceKeepAlive(); + + // Force shorter timeout if we think we might be offline + resource.forceKeepAlive( + this.isNavigatorOffline ? OFFLINE_KEEPALIVE_TIMEOUT_MS : undefined + ); } private dropAuthenticated( @@ -770,7 +829,8 @@ export class SocketManager extends EventListener { callback: (error: HTTPError) => void ): this; public override on(type: 'statusChange', callback: () => void): this; - public override on(type: 'connectError', callback: () => void): this; + public override on(type: 'online', callback: () => void): this; + public override on(type: 'offline', callback: () => void): this; public override on( type: string | symbol, @@ -782,7 +842,8 @@ export class SocketManager extends EventListener { public override emit(type: 'authError', error: HTTPError): boolean; public override emit(type: 'statusChange'): boolean; - public override emit(type: 'connectError'): boolean; + public override emit(type: 'online'): boolean; + public override emit(type: 'offline'): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any public override emit(type: string | symbol, ...args: Array): boolean { diff --git a/ts/textsecure/UpdateKeysListener.ts b/ts/textsecure/UpdateKeysListener.ts index 26558cd91e..aefb7e0c8d 100644 --- a/ts/textsecure/UpdateKeysListener.ts +++ b/ts/textsecure/UpdateKeysListener.ts @@ -94,17 +94,17 @@ export class UpdateKeysListener { } private runWhenOnline() { - if (window.navigator.onLine) { + if (window.textsecure.server?.isOnline()) { void this.run(); } else { log.info( 'UpdateKeysListener: We are offline; will update keys when we are next online' ); const listener = () => { - window.removeEventListener('online', listener); + window.Whisper.events.off('online', listener); this.setTimeoutForNextRun(); }; - window.addEventListener('online', listener); + window.Whisper.events.on('online', listener); } } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index ebe5df1550..5bfa21097d 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1154,8 +1154,10 @@ export type WebAPIType = { unregisterRequestHandler: (handler: IRequestHandler) => void; onHasStoriesDisabledChange: (newValue: boolean) => void; checkSockets: () => void; - onOnline: () => Promise; - onOffline: () => void; + isOnline: () => boolean; + onNavigatorOnline: () => Promise; + onNavigatorOffline: () => Promise; + onRemoteExpiration: () => Promise; reconnect: () => Promise; }; @@ -1321,12 +1323,16 @@ export function initialize({ window.Whisper.events.trigger('socketStatusChange'); }); - socketManager.on('authError', () => { - window.Whisper.events.trigger('unlinkAndDisconnect'); + socketManager.on('online', () => { + window.Whisper.events.trigger('online'); }); - socketManager.on('connectError', () => { - window.Whisper.events.trigger('socketConnectError'); + socketManager.on('offline', () => { + window.Whisper.events.trigger('offline'); + }); + + socketManager.on('authError', () => { + window.Whisper.events.trigger('unlinkAndDisconnect'); }); if (useWebSocket) { @@ -1442,8 +1448,10 @@ export function initialize({ modifyGroup, modifyStorageRecords, onHasStoriesDisabledChange, - onOffline, - onOnline, + isOnline, + onNavigatorOffline, + onNavigatorOnline, + onRemoteExpiration, postBatchIdentityCheck, putEncryptedAttachment, putProfile, @@ -1620,12 +1628,20 @@ export function initialize({ void socketManager.check(); } - async function onOnline(): Promise { - await socketManager.onOnline(); + function isOnline(): boolean { + return socketManager.isOnline; } - function onOffline(): void { - socketManager.onOffline(); + async function onNavigatorOnline(): Promise { + await socketManager.onNavigatorOnline(); + } + + async function onNavigatorOffline(): Promise { + await socketManager.onNavigatorOffline(); + } + + async function onRemoteExpiration(): Promise { + await socketManager.onRemoteExpiration(); } async function reconnect(): Promise { diff --git a/ts/textsecure/WebsocketResources.ts b/ts/textsecure/WebsocketResources.ts index 319787cf63..c0a445c952 100644 --- a/ts/textsecure/WebsocketResources.ts +++ b/ts/textsecure/WebsocketResources.ts @@ -42,6 +42,7 @@ import EventTarget from './EventTarget'; import * as durations from '../util/durations'; import { dropNull } from '../util/dropNull'; +import { drop } from '../util/drop'; import { isOlderThan } from '../util/timestamp'; import { strictAssert } from '../util/assert'; import * as Errors from '../types/errors'; @@ -52,7 +53,6 @@ import type { IResource } from './WebSocket'; import { isProduction, isStaging } from '../util/version'; import { ToastType } from '../types/Toast'; -import { drop } from '../util/drop'; const THIRTY_SECONDS = 30 * durations.SECOND; @@ -251,7 +251,7 @@ export interface IWebSocketResource extends IResource { addEventListener(name: 'close', handler: (ev: CloseEvent) => void): void; - forceKeepAlive(): void; + forceKeepAlive(timeout?: number): void; shutdown(): void; @@ -395,8 +395,8 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource { this.shadowing.shutdown(); } - public forceKeepAlive(): void { - this.main.forceKeepAlive(); + public forceKeepAlive(timeout?: number): void { + this.main.forceKeepAlive(timeout); } public async sendRequest(options: SendRequestOptions): Promise { @@ -627,11 +627,11 @@ export default class WebSocketResource return WebSocketResource.intoResponse(requestResult); } - public forceKeepAlive(): void { + public forceKeepAlive(timeout?: number): void { if (!this.keepalive) { return; } - void this.keepalive.send(); + drop(this.keepalive.send(timeout)); } public close(code = 3000, reason?: string): void { @@ -853,7 +853,7 @@ class KeepAlive { this.clearTimers(); } - public async send(): Promise { + public async send(timeout = KEEPALIVE_TIMEOUT_MS): Promise { this.clearTimers(); const isStale = isOlderThan(this.lastAliveAt, STALE_THRESHOLD_MS); @@ -875,7 +875,7 @@ class KeepAlive { verb: 'GET', path: this.path, }), - KEEPALIVE_TIMEOUT_MS + timeout ); if (status < 200 || status >= 300) { diff --git a/ts/types/Stickers.ts b/ts/types/Stickers.ts index fadc4f6c32..e236ee0643 100644 --- a/ts/types/Stickers.ts +++ b/ts/types/Stickers.ts @@ -596,8 +596,13 @@ async function doDownloadStickerPack( return; } + const { server } = window.textsecure; + if (!server) { + throw new Error('server is not available!'); + } + // We don't count this as an attempt if we're offline - const attemptIncrement = navigator.onLine ? 1 : 0; + const attemptIncrement = server.isOnline() ? 1 : 0; const downloadAttempts = (existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement; if (downloadAttempts > 3) { diff --git a/ts/util/BackOff.ts b/ts/util/BackOff.ts index baffd9380a..a68c4fe5b6 100644 --- a/ts/util/BackOff.ts +++ b/ts/util/BackOff.ts @@ -15,6 +15,17 @@ export const FIBONACCI_TIMEOUTS: ReadonlyArray = [ 55 * SECOND, ]; +export const EXTENDED_FIBONACCI_TIMEOUTS: ReadonlyArray = [ + ...FIBONACCI_TIMEOUTS, + 89 * SECOND, + 144 * SECOND, + 233 * SECOND, + 377 * SECOND, + 610 * SECOND, + 987 * SECOND, + 1597 * SECOND, // ~26 minutes +]; + export type BackOffOptionsType = Readonly<{ jitter?: number; @@ -28,7 +39,7 @@ export class BackOff { private count = 0; constructor( - private readonly timeouts: ReadonlyArray, + private timeouts: ReadonlyArray, private readonly options: BackOffOptionsType = {} ) {} @@ -53,7 +64,10 @@ export class BackOff { return result; } - public reset(): void { + public reset(newTimeouts?: ReadonlyArray): void { + if (newTimeouts !== undefined) { + this.timeouts = newTimeouts; + } this.count = 0; } diff --git a/ts/util/waitForOnline.ts b/ts/util/waitForOnline.ts index e61bfe8da8..c5fcf84fa3 100644 --- a/ts/util/waitForOnline.ts +++ b/ts/util/waitForOnline.ts @@ -3,15 +3,31 @@ import { clearTimeoutIfNecessary } from './clearTimeoutIfNecessary'; -export function waitForOnline( - navigator: Readonly<{ onLine: boolean }>, - onlineEventTarget: EventTarget, - options: Readonly<{ timeout?: number }> = {} -): Promise { - const { timeout } = options; +export type WaitForOnlineOptionsType = Readonly<{ + server?: Readonly<{ isOnline: () => boolean }>; + events?: { + on: (event: 'online', fn: () => void) => void; + off: (event: 'online', fn: () => void) => void; + }; + timeout?: number; +}>; +export function waitForOnline({ + server: maybeServer, + events = window.Whisper.events, + timeout, +}: WaitForOnlineOptionsType = {}): Promise { return new Promise((resolve, reject) => { - if (navigator.onLine) { + let server = maybeServer; + if (server === undefined) { + ({ server } = window.textsecure); + if (!server) { + reject(new Error('waitForOnline: no textsecure server')); + return; + } + } + + if (server.isOnline()) { resolve(); return; } @@ -24,11 +40,11 @@ export function waitForOnline( }; const cleanup = () => { - onlineEventTarget.removeEventListener('online', listener); + events.off('online', listener); clearTimeoutIfNecessary(timeoutId); }; - onlineEventTarget.addEventListener('online', listener); + events.on('online', listener); if (timeout !== undefined) { timeoutId = setTimeout(() => {