diff --git a/ts/components/installScreen/InstallScreenErrorStep.tsx b/ts/components/installScreen/InstallScreenErrorStep.tsx index b8b3981a91de..15e2fc019d5a 100644 --- a/ts/components/installScreen/InstallScreenErrorStep.tsx +++ b/ts/components/installScreen/InstallScreenErrorStep.tsx @@ -1,8 +1,9 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReactElement } from 'react'; -import React from 'react'; +import React, { type ReactElement, useEffect, useCallback } from 'react'; +import { noop } from 'lodash'; + import type { LocalizerType } from '../../types/Util'; import { missingCaseError } from '../../util/missingCaseError'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; @@ -27,9 +28,31 @@ export function InstallScreenErrorStep({ }: Props): ReactElement { let errorMessage: string; let buttonText = i18n('icu:installTryAgain'); - let onClickButton = () => tryAgain(); + let onClickButton = useCallback(() => tryAgain(), [tryAgain]); let shouldShowQuitButton = false; + useEffect(() => { + if (error !== InstallScreenError.InactiveTimeout) { + return noop; + } + + const cleanup = () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + + const onVisibilityChange = () => { + if (document.hidden) { + return; + } + + cleanup(); + tryAgain(); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + return cleanup; + }, [error, tryAgain]); + switch (error) { case InstallScreenError.TooManyDevices: errorMessage = i18n('icu:installTooManyDevices'); @@ -43,6 +66,7 @@ export function InstallScreenErrorStep({ shouldShowQuitButton = true; break; case InstallScreenError.ConnectionFailed: + case InstallScreenError.InactiveTimeout: errorMessage = i18n('icu:installConnectionFailed'); break; case InstallScreenError.QRCodeFailed: diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index 97ff2f3c6200..53d010eda7fc 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -19,7 +19,7 @@ import { strictAssert } from '../../util/assert'; import { SECOND } from '../../util/durations'; import * as Registration from '../../util/registration'; import { isBackupEnabled } from '../../util/isBackupEnabled'; -import { HTTPError } from '../../textsecure/Errors'; +import { HTTPError, InactiveTimeoutError } from '../../textsecure/Errors'; import { Provisioner, type PrepareLinkDataOptionsType, @@ -196,7 +196,10 @@ function startInstaller(): ThunkAction< const { server } = window.textsecure; strictAssert(server, 'Expected a server'); - const provisioner = new Provisioner(server, window.getVersion()); + const provisioner = new Provisioner({ + server, + appVersion: window.getVersion(), + }); const abortController = new AbortController(); const { signal } = abortController; @@ -295,6 +298,14 @@ function startInstaller(): ThunkAction< Errors.toLogFormat(error) ); + if (error instanceof InactiveTimeoutError) { + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.InactiveTimeout, + }); + return; + } + dispatch({ type: SET_ERROR, payload: InstallScreenError.ConnectionFailed, diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts index eb7d1b7f8478..0aa63d5eb0bb 100644 --- a/ts/textsecure/Errors.ts +++ b/ts/textsecure/Errors.ts @@ -315,3 +315,9 @@ export class IncorrectSenderKeyAuthError extends Error {} export class WarnOnlyError extends Error {} export class NoSenderKeyError extends Error {} + +export class InactiveTimeoutError extends Error { + constructor() { + super('Closing socket due to inactivity'); + } +} diff --git a/ts/textsecure/Provisioner.ts b/ts/textsecure/Provisioner.ts index 62f1469e4154..c0ef51fde242 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -10,6 +10,7 @@ import { strictAssert } from '../util/assert'; import { normalizeAci } from '../util/normalizeAci'; import { normalizeDeviceName } from '../util/normalizeDeviceName'; import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled'; +import { MINUTE } from '../util/durations'; import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen'; import * as Errors from '../types/errors'; import { @@ -33,6 +34,7 @@ import { type IncomingWebSocketRequest, ServerRequestType, } from './WebsocketResources'; +import { InactiveTimeoutError } from './Errors'; enum Step { Idle = 'Idle', @@ -72,16 +74,25 @@ export type PrepareLinkDataOptionsType = Readonly<{ backupFile?: Uint8Array; }>; +export type ProvisionerOptionsType = Readonly<{ + server: WebAPIType; + appVersion: string; +}>; + +const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE; + export class Provisioner { private readonly cipher = new ProvisioningCipher(); + private readonly server: WebAPIType; + private readonly appVersion: string; private state: StateType = { step: Step.Idle }; private wsr: IWebSocketResource | undefined; - constructor( - private readonly server: WebAPIType, - private readonly appVersion: string - ) {} + constructor(options: ProvisionerOptionsType) { + this.server = options.server; + this.appVersion = options.appVersion; + } public close(error = new Error('Provisioner closed')): void { try { @@ -122,6 +133,32 @@ export class Provisioner { }); this.wsr = wsr; + let inactiveTimer: NodeJS.Timeout | undefined; + + const onVisibilityChange = (): void => { + // Visible + if (!document.hidden) { + if (inactiveTimer != null) { + clearTimeout(inactiveTimer); + } + inactiveTimer = undefined; + return; + } + + // Invisible, but already has a timer + if (inactiveTimer != null) { + return; + } + + inactiveTimer = setTimeout(() => { + inactiveTimer = undefined; + + this.close(new InactiveTimeoutError()); + }, INACTIVE_SOCKET_TIMEOUT); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + if (this.state.step !== Step.Connecting) { this.close(); throw new Error('Provisioner closed early'); @@ -133,6 +170,13 @@ export class Provisioner { }; wsr.addEventListener('close', ({ code, reason }) => { + // Unsubscribe from visibility changes + document.removeEventListener('visibilitychange', onVisibilityChange); + if (inactiveTimer != null) { + clearTimeout(inactiveTimer); + } + inactiveTimer = undefined; + if (this.state.step === Step.ReadyToLink) { // WebSocket close is not an issue since we no longer need it return; diff --git a/ts/types/InstallScreen.ts b/ts/types/InstallScreen.ts index 427d9d3a3c48..701148fc72d8 100644 --- a/ts/types/InstallScreen.ts +++ b/ts/types/InstallScreen.ts @@ -22,6 +22,7 @@ export enum InstallScreenError { TooOld = 'TooOld', ConnectionFailed = 'ConnectionFailed', QRCodeFailed = 'QRCodeFailed', + InactiveTimeout = 'InactiveTimeout', } export enum InstallScreenQRCodeError {