diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4145135af..c0559d9f8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1582,6 +1582,10 @@ "messageformat": "Retry", "description": "Text of the button shown on the install screen if the QR code fails to load" }, + "icu:Install__qr-max-rotations__retry": { + "messageformat": "Refresh code", + "description": "Text of the button shown on the install screen if the QR code rotated too many times and needs to be manually refreshed" + }, "icu:Install__qr-failed-load__get-help": { "messageformat": "Get help", "description": "Text of the link to support page shown on the install screen if the QR code fails to load" diff --git a/stylesheets/components/InstallScreenQrCodeNotScannedStep.scss b/stylesheets/components/InstallScreenQrCodeNotScannedStep.scss index e0a16dc30..3e0341cbc 100644 --- a/stylesheets/components/InstallScreenQrCodeNotScannedStep.scss +++ b/stylesheets/components/InstallScreenQrCodeNotScannedStep.scss @@ -58,6 +58,28 @@ color: variables.$color-gray-60; } + &--just-button { + background: variables.$color-gray-05; + } + + &--just-button &__link { + background: variables.$color-white; + color: variables.$color-black; + padding-block: 8px; + padding-inline: 16px; + border-radius: 34px; + @include mixins.font-body-1; + font-weight: 500; + margin-block-start: 0; + + &::before { + @include mixins.color-svg( + '../images/icons/v3/refresh/refresh-bold.svg', + variables.$color-black + ); + } + } + &__link { @include mixins.button-reset; & { diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx index f775f465c..83a33f64b 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx @@ -128,6 +128,17 @@ export function SimulatedLoading(): JSX.Element { return ; } +export function SimulatedMaxRotationsError(): JSX.Element { + return ( + + ); +} + export function SimulatedUnknownError(): JSX.Element { return ( ; @@ -146,7 +148,9 @@ function InstallScreenQrCode( > {i18n('icu:Install__qr-failed-load__error--timeout')} - + + {i18n('icu:Install__qr-failed-load__retry')} + ); break; @@ -162,7 +166,9 @@ function InstallScreenQrCode( components={{ paragraph: Paragraph }} /> - + + {i18n('icu:Install__qr-failed-load__retry')} + ); break; @@ -186,6 +192,14 @@ function InstallScreenQrCode( ); break; + case InstallScreenQRCodeError.MaxRotations: + isJustButton = true; + contents = ( + + {i18n('icu:Install__qr-max-rotations__retry')} + + ); + break; default: throw missingCaseError(props.error); } @@ -210,7 +224,8 @@ function InstallScreenQrCode( props.loadingState === LoadingState.Loaded && getQrCodeClassName('--loaded'), props.loadingState === LoadingState.LoadFailed && - getQrCodeClassName('--load-failed') + getQrCodeClassName('--load-failed'), + isJustButton && getQrCodeClassName('--just-button') )} > {contents} @@ -219,11 +234,11 @@ function InstallScreenQrCode( } function RetryButton({ - i18n, onClick, + children, }: { - i18n: LocalizerType; onClick: () => void; + children: ReactNode; }): JSX.Element { const onKeyDown = useCallback( (ev: React.KeyboardEvent) => { @@ -243,7 +258,7 @@ function RetryButton({ onKeyDown={onKeyDown} type="button" > - {i18n('icu:Install__qr-failed-load__retry')} + {children} ); } diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index c4da8cf44..433219c0b 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -3,7 +3,6 @@ import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; -import pTimeout, { TimeoutError } from 'p-timeout'; import type { StateType as RootStateType } from '../reducer'; import { @@ -17,13 +16,14 @@ import * as Errors from '../../types/errors'; import { type Loadable, LoadingState } from '../../util/loadable'; import { isRecord } from '../../util/isRecord'; import { strictAssert } from '../../util/assert'; -import { SECOND } from '../../util/durations'; import * as Registration from '../../util/registration'; import { isBackupEnabled } from '../../util/isBackupEnabled'; -import { HTTPError, InactiveTimeoutError } from '../../textsecure/Errors'; +import { missingCaseError } from '../../util/missingCaseError'; +import { HTTPError } from '../../textsecure/Errors'; import { Provisioner, - type PrepareLinkDataOptionsType, + EventKind as ProvisionEventKind, + type EnvelopeType as ProvisionEnvelopeType, } from '../../textsecure/Provisioner'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; @@ -31,14 +31,10 @@ import * as log from '../../logging/log'; import { backupsService } from '../../services/backups'; import OS from '../../util/os/osMain'; -const SLEEP_ERROR = new TimeoutError(); - -const QR_CODE_TIMEOUTS = [10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND]; - export type BatonType = ReadonlyDeep<{ __installer_baton: never }>; -const controllerByBaton = new WeakMap(); -const provisionerByBaton = new WeakMap(); +const cancelByBaton = new WeakMap void>(); +let provisioner: Provisioner | undefined; export type InstallerStateType = ReadonlyDeep< | { @@ -48,12 +44,12 @@ export type InstallerStateType = ReadonlyDeep< step: InstallScreenStep.QrCodeNotScanned; provisioningUrl: Loadable; baton: BatonType; - attemptCount: number; } | { step: InstallScreenStep.ChoosingDeviceName; deviceName: string; backupFile?: File; + envelope: ProvisionEnvelopeType; baton: BatonType; } | { @@ -118,6 +114,7 @@ type QRCodeScannedActionType = ReadonlyDeep<{ payload: { deviceName: string; baton: BatonType; + envelope: ProvisionEnvelopeType; }; }>; @@ -193,82 +190,49 @@ function startInstaller(): ThunkAction< state.step === InstallScreenStep.QrCodeNotScanned, 'Unexpected step after START_INSTALLER' ); - const { attemptCount } = state; - - // Can't retry past attempt count - if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) { - log.error('InstallScreen/getQRCode: too many tries'); - dispatch({ - type: SET_ERROR, - payload: InstallScreenError.QRCodeFailed, - }); - return; - } const { server } = window.textsecure; strictAssert(server, 'Expected a server'); - const provisioner = new Provisioner({ - server, - appVersion: window.getVersion(), - }); - - const abortController = new AbortController(); - const { signal } = abortController; - signal.addEventListener('abort', () => { - provisioner.close(); - }); - - controllerByBaton.set(baton, abortController); - - // Wait to get QR code - try { - const qrCodePromise = provisioner.getURL(); - const sleepMs = QR_CODE_TIMEOUTS[attemptCount]; - log.info(`installer/getQRCode: race to ${sleepMs}ms`); - - const url = await pTimeout(qrCodePromise, sleepMs, SLEEP_ERROR); - if (signal.aborted) { - return; - } - - dispatch({ - type: SET_PROVISIONING_URL, - payload: url, + if (!provisioner) { + provisioner = new Provisioner({ + server, + appVersion: window.getVersion(), }); - } catch (error) { - provisioner.close(); + } - if (signal.aborted) { - return; - } - - log.error( - 'installer: got an error while waiting for QR code', - Errors.toLogFormat(error) - ); - - // Too many attempts, there is probably some issue - if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) { - log.error('InstallScreen/getQRCode: too many tries'); - dispatch({ - type: SET_ERROR, - payload: InstallScreenError.QRCodeFailed, - }); - return; - } - - // Timed out, let user retry - if (error === SLEEP_ERROR) { + const cancel = provisioner.subscribe(event => { + if (event.kind === ProvisionEventKind.MaxRotationsError) { + log.warn('InstallScreen/getQRCode: max rotations reached'); dispatch({ type: SET_QR_CODE_ERROR, - payload: InstallScreenQRCodeError.Timeout, + payload: InstallScreenQRCodeError.MaxRotations, }); - return; - } + } else if (event.kind === ProvisionEventKind.TimeoutError) { + if (event.canRetry) { + log.warn('InstallScreen/getQRCode: timed out'); + dispatch({ + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.Timeout, + }); + } else { + log.error('InstallScreen/getQRCode: too many tries'); + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.QRCodeFailed, + }); + } + } else if (event.kind === ProvisionEventKind.ConnectError) { + const { error } = event; + + log.error( + 'installer: got an error while waiting for QR code', + Errors.toLogFormat(error) + ); - if (error instanceof HTTPError && error.code === -1) { if ( + error instanceof HTTPError && + error.code === -1 && isRecord(error.cause) && error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN' ) { @@ -278,89 +242,89 @@ function startInstaller(): ThunkAction< }); return; } + dispatch({ type: SET_ERROR, payload: InstallScreenError.ConnectionFailed, }); - return; - } + } else if (event.kind === ProvisionEventKind.EnvelopeError) { + log.error( + 'installer: got an error while waiting for envelope', + Errors.toLogFormat(event.error) + ); - dispatch({ - type: SET_QR_CODE_ERROR, - payload: InstallScreenQRCodeError.Unknown, - }); - return; - } - - if (signal.aborted) { - log.warn('installer/startInstaller: aborted'); - return; - } - - // Wait for primary device to scan QR code and get back to us - - try { - await provisioner.waitForEnvelope(); - } catch (error) { - if (signal.aborted) { - return; - } - log.error( - 'installer: got an error while waiting for envelope code', - Errors.toLogFormat(error) - ); - - if (error instanceof InactiveTimeoutError) { dispatch({ - type: SET_ERROR, - payload: InstallScreenError.InactiveTimeout, + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.Unknown, }); - return; - } + } else if (event.kind === ProvisionEventKind.URL) { + window.SignalCI?.setProvisioningURL(event.url); + dispatch({ + type: SET_PROVISIONING_URL, + payload: event.url, + }); + } else if (event.kind === ProvisionEventKind.Envelope) { + const { envelope } = event; - dispatch({ - type: SET_ERROR, - payload: InstallScreenError.ConnectionFailed, - }); - return; - } - - if (signal.aborted) { - return; - } - provisionerByBaton.set(baton, provisioner); - - if (provisioner.isLinkAndSync()) { - dispatch(finishInstall({ deviceName: OS.getName() || 'Signal Desktop' })); - } else { - // Show screen to choose device name - dispatch({ - type: QR_CODE_SCANNED, - payload: { - deviceName: + if (event.isLinkAndSync) { + const deviceName = OS.getName() || 'Signal Desktop'; + dispatch( + finishInstall({ + envelope, + deviceName, + isLinkAndSync: true, + }) + ); + } else { + const deviceName = window.textsecure.storage.user.getDeviceName() || window.getHostName() || - '', - baton, - }, - }); + ''; - // And feed it the CI data if present - const { SignalCI } = window; - if (SignalCI != null) { - dispatch( - finishInstall({ - deviceName: SignalCI.deviceName, - }) - ); + // Show screen to choose device name + dispatch({ + type: QR_CODE_SCANNED, + payload: { + deviceName, + envelope, + baton, + }, + }); + + // And feed it the CI data if present + const { SignalCI } = window; + if (SignalCI != null) { + dispatch( + finishInstall({ + envelope, + deviceName: SignalCI.deviceName, + isLinkAndSync: false, + }) + ); + } + } + } else { + throw missingCaseError(event); } - } + }); + + cancelByBaton.set(baton, cancel); }; } -function finishInstall( - options: PrepareLinkDataOptionsType -): ThunkAction< +type FinishInstallOptionsType = ReadonlyDeep<{ + isLinkAndSync: boolean; + deviceName: string; + envelope?: ProvisionEnvelopeType; + backupFile?: Uint8Array; +}>; + +function finishInstall({ + isLinkAndSync, + envelope: providedEnvelope, + deviceName, + backupFile, +}: FinishInstallOptionsType): ThunkAction< void, RootStateType, unknown, @@ -371,42 +335,46 @@ function finishInstall( > { return async (dispatch, getState) => { const state = getState(); - strictAssert( - state.installer.step === InstallScreenStep.ChoosingDeviceName || - state.installer.step === InstallScreenStep.QrCodeNotScanned, - 'Wrong step' - ); - - const { baton } = state.installer; - const provisioner = provisionerByBaton.get(baton); strictAssert( provisioner != null, 'Provisioner is not waiting for device info' ); + let envelope: ProvisionEnvelopeType; if (state.installer.step === InstallScreenStep.QrCodeNotScanned) { + strictAssert(isLinkAndSync, 'Can only skip device naming if link & sync'); strictAssert( - provisioner.isLinkAndSync(), - 'Can only skip device naming if link & sync' + providedEnvelope != null, + 'finishInstall: missing required envelope' ); + envelope = providedEnvelope; + } else if (state.installer.step === InstallScreenStep.ChoosingDeviceName) { + ({ envelope } = state.installer); + } else { + throw new Error('Wrong step'); } // Cleanup - controllerByBaton.delete(baton); - provisionerByBaton.delete(baton); + const { baton } = state.installer; + cancelByBaton.delete(baton); const accountManager = window.getAccountManager(); strictAssert(accountManager, 'Expected an account manager'); - if (isBackupEnabled() || provisioner.isLinkAndSync()) { + if (isBackupEnabled() || isLinkAndSync) { dispatch({ type: SHOW_BACKUP_IMPORT }); } else { dispatch({ type: SHOW_LINK_IN_PROGRESS }); } try { - const data = provisioner.prepareLinkData(options); - await accountManager.registerSecondDevice(data); + await accountManager.registerSecondDevice( + Provisioner.prepareLinkData({ + envelope, + deviceName, + backupFile, + }) + ); window.IPC.removeSetupMenuItems(); } catch (error) { if (error instanceof HTTPError) { @@ -498,8 +466,11 @@ export function reducer( if (action.type === START_INSTALLER) { // Abort previous install if (state.step === InstallScreenStep.QrCodeNotScanned) { - const controller = controllerByBaton.get(state.baton); - controller?.abort(); + const cancel = cancelByBaton.get(state.baton); + cancel?.(); + } else { + // Reset qr code fetch attempt count when starting from scratch + provisioner?.reset(); } return { @@ -508,17 +479,15 @@ export function reducer( loadingState: LoadingState.Loading, }, baton: action.payload, - attemptCount: - state.step === InstallScreenStep.QrCodeNotScanned - ? state.attemptCount + 1 - : 0, }; } if (action.type === SET_PROVISIONING_URL) { if ( state.step !== InstallScreenStep.QrCodeNotScanned || - state.provisioningUrl.loadingState !== LoadingState.Loading + (state.provisioningUrl.loadingState !== LoadingState.Loading && + // Rotating + state.provisioningUrl.loadingState !== LoadingState.Loaded) ) { log.warn('ducks/installer: not setting provisioning url', state.step); return state; @@ -536,7 +505,11 @@ export function reducer( if (action.type === SET_QR_CODE_ERROR) { if ( state.step !== InstallScreenStep.QrCodeNotScanned || - state.provisioningUrl.loadingState !== LoadingState.Loading + !( + state.provisioningUrl.loadingState === LoadingState.Loading || + // Rotating + state.provisioningUrl.loadingState === LoadingState.Loaded + ) ) { log.warn('ducks/installer: not setting qr code error', state.step); return state; @@ -570,6 +543,7 @@ export function reducer( return { step: InstallScreenStep.ChoosingDeviceName, deviceName: action.payload.deviceName, + envelope: action.payload.envelope, baton: action.payload.baton, }; } diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index ebd485002..e6a381410 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -41,9 +41,17 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { const onSubmitDeviceName = useCallback(async () => { if (backupFile != null) { // This is only for testing so don't bother catching errors - finishInstall({ deviceName, backupFile: await fileToBytes(backupFile) }); + finishInstall({ + deviceName, + backupFile: await fileToBytes(backupFile), + isLinkAndSync: false, + }); } else { - finishInstall({ deviceName, backupFile: undefined }); + finishInstall({ + deviceName, + backupFile: undefined, + isLinkAndSync: false, + }); } }, [backupFile, deviceName, finishInstall]); diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts index 0aa63d5eb..eb7d1b7f8 100644 --- a/ts/textsecure/Errors.ts +++ b/ts/textsecure/Errors.ts @@ -315,9 +315,3 @@ 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 e44696949..0dca38c4e 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -1,212 +1,157 @@ -// Copyright 2024 Signal Messenger, LLC +// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { - type ExplodePromiseResultType, - explodePromise, -} from '../util/explodePromise'; -import { linkDeviceRoute } from '../util/signalRoutes'; -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 pTimeout, { TimeoutError as PTimeoutError } from 'p-timeout'; + +import * as log from '../logging/log'; import * as Errors from '../types/errors'; +import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen'; import { isUntaggedPniString, normalizePni, toTaggedPni, } from '../types/ServiceId'; -import { SignalService as Proto } from '../protobuf'; +import { strictAssert } from '../util/assert'; +import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff'; +import { SECOND } from '../util/durations'; +import { explodePromise } from '../util/explodePromise'; +import { drop } from '../util/drop'; +import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled'; +import { normalizeAci } from '../util/normalizeAci'; +import { normalizeDeviceName } from '../util/normalizeDeviceName'; +import { linkDeviceRoute } from '../util/signalRoutes'; +import { sleep } from '../util/sleep'; import * as Bytes from '../Bytes'; -import * as log from '../logging/log'; -import { type WebAPIType } from './WebAPI'; -import ProvisioningCipher, { - type ProvisionDecryptResult, -} from './ProvisioningCipher'; +import { SignalService as Proto } from '../protobuf'; + import { type CreateLinkedDeviceOptionsType, AccountType, } from './AccountManager'; +import ProvisioningCipher, { + type ProvisionDecryptResult, +} from './ProvisioningCipher'; import { type IWebSocketResource, type IncomingWebSocketRequest, ServerRequestType, } from './WebsocketResources'; -import { InactiveTimeoutError } from './Errors'; +import { ConnectTimeoutError } from './Errors'; +import { type WebAPIType } from './WebAPI'; -enum Step { - Idle = 'Idle', - Connecting = 'Connecting', - WaitingForURL = 'WaitingForURL', - WaitingForEnvelope = 'WaitingForEnvelope', - ReadyToLink = 'ReadyToLink', - Done = 'Done', +export enum EventKind { + MaxRotationsError = 'MaxRotationsError', + TimeoutError = 'TimeoutError', + ConnectError = 'ConnectError', + EnvelopeError = 'EnvelopeError', + URL = 'URL', + Envelope = 'Envelope', } -type StateType = Readonly< - | { - step: Step.Idle; - } - | { - step: Step.Connecting; - } - | { - step: Step.WaitingForURL; - url: ExplodePromiseResultType; - } - | { - step: Step.WaitingForEnvelope; - done: ExplodePromiseResultType; - } - | { - step: Step.ReadyToLink; - envelope: ProvisionDecryptResult; - } - | { - step: Step.Done; - } ->; - -export type PrepareLinkDataOptionsType = Readonly<{ - deviceName: string; - backupFile?: Uint8Array; -}>; - export type ProvisionerOptionsType = Readonly<{ server: WebAPIType; appVersion: string; }>; -const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE; +export type EnvelopeType = ProvisionDecryptResult; + +export type EventType = Readonly< + | { + kind: EventKind.MaxRotationsError; + } + | { + kind: EventKind.TimeoutError; + canRetry: boolean; + } + | { + kind: EventKind.ConnectError; + error: Error; + } + | { + kind: EventKind.EnvelopeError; + error: Error; + } + | { + kind: EventKind.URL; + url: string; + } + | { + kind: EventKind.Envelope; + envelope: EnvelopeType; + isLinkAndSync: boolean; + } +>; + +export type SubscribeNotifierType = (event: EventType) => void; + +export type UnsubscribeFunctionType = () => void; + +export type SubscriberType = Readonly<{ + notify: SubscribeNotifierType; +}>; + +export type PrepareLinkDataOptionsType = Readonly<{ + envelope: EnvelopeType; + deviceName: string; + backupFile?: Uint8Array; +}>; + +enum SocketState { + WaitingForUuid = 'WaitingForUuid', + WaitingForEnvelope = 'WaitingForEnvelope', + Done = 'Done', +} + +const ROTATION_INTERVAL = 45 * SECOND; +const MAX_OPEN_SOCKETS = 2; +const MAX_ROTATIONS = 6; + +const TIMEOUT_ERROR = new PTimeoutError(); + +const QR_CODE_TIMEOUTS = [10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND]; export class Provisioner { - readonly #cipher = new ProvisioningCipher(); + readonly #subscribers = new Set(); readonly #server: WebAPIType; readonly #appVersion: string; - #state: StateType = { step: Step.Idle }; - #wsr: IWebSocketResource | undefined; + readonly #retryBackOff = new BackOff(FIBONACCI_TIMEOUTS); - constructor(options: ProvisionerOptionsType) { - this.#server = options.server; - this.#appVersion = options.appVersion; + #sockets: Array = []; + #abortController: AbortController | undefined; + #attemptCount = 0; + #isRunning = false; + + constructor({ server, appVersion }: ProvisionerOptionsType) { + this.#server = server; + this.#appVersion = appVersion; } - public close(error = new Error('Provisioner closed')): void { - try { - this.#wsr?.close(); - } catch { - // Best effort + public subscribe(notify: SubscribeNotifierType): UnsubscribeFunctionType { + const subscriber = { notify }; + + this.#subscribers.add(subscriber); + if (this.#subscribers.size === 1) { + this.#start(); } - const prevState = this.#state; - this.#state = { step: Step.Done }; - - if (prevState.step === Step.WaitingForURL) { - prevState.url.reject(error); - } else if (prevState.step === Step.WaitingForEnvelope) { - prevState.done.reject(error); - } - } - - public async getURL(): Promise { - strictAssert( - this.#state.step === Step.Idle, - `Invalid state for getURL: ${this.#state.step}` - ); - this.#state = { step: Step.Connecting }; - - const wsr = await this.#server.getProvisioningResource({ - handleRequest: (request: IncomingWebSocketRequest) => { - try { - this.#handleRequest(request); - } catch (error) { - log.error( - 'Provisioner.handleRequest: failure', - Errors.toLogFormat(error) - ); - this.close(); - } - }, - }); - this.#wsr = wsr; - - let inactiveTimer: NodeJS.Timeout | undefined; - - const onVisibilityChange = (): void => { - // Visible - if (!document.hidden) { - if (inactiveTimer != null) { - clearTimeout(inactiveTimer); - } - inactiveTimer = undefined; - return; + return () => { + this.#subscribers.delete(subscriber); + if (this.#subscribers.size === 0) { + this.#stop('Cancel, no subscribers'); } - - // 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'); - } - - this.#state = { - step: Step.WaitingForURL, - url: explodePromise(), - }; - - 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; - } - - log.info(`provisioning socket closed. Code: ${code} Reason: ${reason}`); - this.close(new Error('websocket closed')); - }); - - return this.#state.url.promise; } - public async waitForEnvelope(): Promise { - strictAssert( - this.#state.step === Step.WaitingForEnvelope, - `Invalid state for waitForEnvelope: ${this.#state.step}` - ); - await this.#state.done.promise; + public reset(): void { + this.#attemptCount = 0; + this.#retryBackOff.reset(); } - public prepareLinkData({ + public static prepareLinkData({ + envelope, deviceName, backupFile, }: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType { - strictAssert( - this.#state.step === Step.ReadyToLink, - `Invalid state for prepareLinkData: ${this.#state.step}` - ); - const { envelope } = this.#state; - this.#state = { step: Step.Done }; - const { number, provisioningCode, @@ -270,72 +215,263 @@ export class Provisioner { }; } - public isLinkAndSync(): boolean { - strictAssert( - this.#state.step === Step.ReadyToLink, - `Invalid state for prepareLinkData: ${this.#state.step}` - ); + // + // Private + // - const { envelope } = this.#state; + #start(): void { + log.info('Provisioniner: starting'); - return ( - isLinkAndSyncEnabled(this.#appVersion) && - Bytes.isNotEmpty(envelope.ephemeralBackupKey) - ); + if (this.#abortController) { + strictAssert(this.#isRunning, 'Must be running to have controller'); + this.#abortController.abort(); + } + this.#abortController = new AbortController(); + + this.#isRunning = true; + + drop(this.#loop(this.#abortController.signal)); } - #handleRequest(request: IncomingWebSocketRequest): void { - const pubKey = this.#cipher.getPublicKey(); + #stop(reason: string): void { + if (!this.#isRunning) { + return; + } + log.info(`Provisioniner: stopping, reason=${reason}`); - if ( - request.requestType === ServerRequestType.ProvisioningAddress && - request.body - ) { - strictAssert( - this.#state.step === Step.WaitingForURL, - `Unexpected provisioning address, state: ${this.#state}` - ); - const prevState = this.#state; - this.#state = { step: Step.WaitingForEnvelope, done: explodePromise() }; + this.#abortController?.abort(); + this.#abortController = undefined; + this.#isRunning = false; + } - const proto = Proto.ProvisioningUuid.decode(request.body); - const { uuid } = proto; - strictAssert(uuid, 'Provisioner.getURL: expected a UUID'); + async #loop(signal: AbortSignal): Promise { + let rotations = 0; + while (this.#subscribers.size > 0) { + const logId = `Provisioner.loop(${rotations})`; - const url = linkDeviceRoute - .toAppUrl({ - uuid, - pubKey: Bytes.toBase64(pubKey), - capabilities: isLinkAndSyncEnabled(this.#appVersion) - ? ['backup'] - : [], - }) - .toString(); + if (rotations >= MAX_ROTATIONS) { + log.info(`${logId}: exceeded max rotation count`); - window.SignalCI?.setProvisioningURL(url); - prevState.url.resolve(url); + this.#notify({ + kind: EventKind.MaxRotationsError, + }); - request.respond(200, 'OK'); - } else if ( - request.requestType === ServerRequestType.ProvisioningMessage && - request.body - ) { - strictAssert( - this.#state.step === Step.WaitingForEnvelope, - `Unexpected provisioning address, state: ${this.#state}` - ); - const prevState = this.#state; + this.#stop('Max rotations reached'); + break; + } - const ciphertext = Proto.ProvisionEnvelope.decode(request.body); - const message = this.#cipher.decrypt(ciphertext); + let delay: number; - this.#state = { step: Step.ReadyToLink, envelope: message }; - request.respond(200, 'OK'); - this.#wsr?.close(); + try { + const sleepMs = QR_CODE_TIMEOUTS[this.#attemptCount]; - prevState.done.resolve(); - } else { - log.error('Unknown websocket message', request.requestType); + // eslint-disable-next-line no-await-in-loop + await this.#connect(signal, sleepMs); + + // Successful connect, sleep until rotation time + delay = ROTATION_INTERVAL; + this.reset(); + rotations += 1; + + log.info(`${logId}: connected, refreshing in ${delay}ms`); + } catch (error) { + // The only active socket has failed, notify subscribers and shutdown + if (this.#sockets.length === 0) { + if (error === TIMEOUT_ERROR || error instanceof ConnectTimeoutError) { + const canRetry = this.#attemptCount < QR_CODE_TIMEOUTS.length - 1; + + this.#attemptCount = Math.min( + this.#attemptCount + 1, + QR_CODE_TIMEOUTS.length - 1 + ); + + this.#notify({ + kind: EventKind.TimeoutError, + canRetry, + }); + } else { + this.#notify({ + kind: EventKind.ConnectError, + error, + }); + } + + this.#subscribers.clear(); + this.#stop('Only socket failed'); + + break; + } + + // At least one more socket is active, retry connecting silently after + // a delay. + + delay = this.#retryBackOff.getAndIncrement(); + + log.error( + `${logId}: failed to connect, retrying in ${delay}ms`, + Errors.toLogFormat(error) + ); + } + + try { + // eslint-disable-next-line no-await-in-loop + await sleep(delay, signal); + } catch (error) { + // Sleep aborted + strictAssert( + this.#subscribers.size === 0, + 'Aborted with active subscribers' + ); + break; + } + } + } + + async #connect(signal: AbortSignal, timeout: number): Promise { + const cipher = new ProvisioningCipher(); + + const uuidPromise = explodePromise(); + + let state = SocketState.WaitingForUuid; + + const timeoutAt = Date.now() + timeout; + + const resource = await this.#server.getProvisioningResource( + { + handleRequest: (request: IncomingWebSocketRequest) => { + const { requestType, body } = request; + if (!body) { + log.warn('Provisioner.connect: no request body'); + request.respond(400, 'Missing body'); + return; + } + + try { + if (requestType === ServerRequestType.ProvisioningAddress) { + strictAssert( + state === SocketState.WaitingForUuid, + 'Provisioner.connect: duplicate uuid' + ); + + const proto = Proto.ProvisioningUuid.decode(body); + strictAssert(proto.uuid, 'Provisioner.connect: expected a UUID'); + + state = SocketState.WaitingForEnvelope; + uuidPromise.resolve(proto.uuid); + request.respond(200, 'OK'); + } else if (requestType === ServerRequestType.ProvisioningMessage) { + strictAssert( + state === SocketState.WaitingForEnvelope, + 'Provisioner.connect: duplicate envelope or not ready' + ); + + const ciphertext = Proto.ProvisionEnvelope.decode(body); + const envelope = cipher.decrypt(ciphertext); + + state = SocketState.Done; + this.#notify({ + kind: EventKind.Envelope, + envelope, + isLinkAndSync: + isLinkAndSyncEnabled(this.#appVersion) && + Bytes.isNotEmpty(envelope.ephemeralBackupKey), + }); + request.respond(200, 'OK'); + } else { + log.warn( + 'Provisioner.connect: unsupported request type', + requestType + ); + request.respond(404, 'Unsupported'); + } + } catch (error) { + log.error('Provisioner.connect: error', Errors.toLogFormat(error)); + resource.close(); + } + }, + }, + timeout + ); + + if (signal.aborted) { + throw new Error('aborted'); + } + + // Setup listeners on the socket + + const onAbort = () => { + resource.close(); + uuidPromise.reject(new Error('aborted')); + }; + signal.addEventListener('abort', onAbort); + + resource.addEventListener('close', ({ code, reason }) => { + signal.removeEventListener('abort', onAbort); + this.#handleClose(resource, state, code, reason); + }); + + // But only register it once we get the uuid from server back. + + const uuid = await pTimeout( + uuidPromise.promise, + Math.max(0, timeoutAt - Date.now()), + TIMEOUT_ERROR + ); + + const url = linkDeviceRoute + .toAppUrl({ + uuid, + pubKey: Bytes.toBase64(cipher.getPublicKey()), + capabilities: isLinkAndSyncEnabled(this.#appVersion) ? ['backup'] : [], + }) + .toString(); + + this.#notify({ kind: EventKind.URL, url }); + + this.#sockets.push(resource); + + while (this.#sockets.length > MAX_OPEN_SOCKETS) { + log.info('Provisioner: closing extra socket'); + this.#sockets.shift()?.close(); + } + } + + #handleClose( + resource: IWebSocketResource, + state: SocketState, + code: number, + reason: string + ): void { + log.info(`Provisioner: socket closed, code=${code}, reason=${reason}`); + + const index = this.#sockets.indexOf(resource); + if (index === -1) { + return; + } + + // Is URL from the socket displayed as a QR code? + const isActive = index === this.#sockets.length - 1; + this.#sockets.splice(index, 1); + + // Graceful closure + if (state === SocketState.Done) { + return; + } + + if (isActive) { + this.#notify({ + kind: + state === SocketState.WaitingForUuid + ? EventKind.ConnectError + : EventKind.EnvelopeError, + error: new Error(`Socket closed, code=${code}, reason=${reason}`), + }); + } + } + + #notify(event: EventType): void { + for (const { notify } of this.#subscribers) { + notify(event); } } } diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index acc62b71b..430af108f 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -349,7 +349,8 @@ export class SocketManager extends EventListener { // Creates new IWebSocketResource for AccountManager's provisioning public async getProvisioningResource( - handler: IRequestHandler + handler: IRequestHandler, + timeout?: number ): Promise { if (this.#isRemotelyExpired) { throw new Error('Remotely expired, not connecting provisioning socket'); @@ -366,6 +367,10 @@ export class SocketManager extends EventListener { }, keepalive: { path: '/v1/keepalive/provisioning' }, }, + extraHeaders: { + 'x-signal-websocket-timeout': 'true', + }, + timeout, }).getResult(); } @@ -704,6 +709,7 @@ export class SocketManager extends EventListener { resourceOptions, query = {}, extraHeaders = {}, + timeout, }: { name: string; path: string; @@ -711,6 +717,7 @@ export class SocketManager extends EventListener { resourceOptions: WebSocketResourceOptions; query?: Record; extraHeaders?: Record; + timeout?: number; }): AbortableProcess { const queryWithDefaults = { agent: 'OWD', @@ -728,6 +735,7 @@ export class SocketManager extends EventListener { version, certificateAuthority: this.options.certificateAuthority, proxyAgent, + timeout, extraHeaders, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 31eb343d0..2dd8612fb 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1401,7 +1401,8 @@ export type WebAPIType = { userLanguages: ReadonlyArray ) => Promise; getProvisioningResource: ( - handler: IRequestHandler + handler: IRequestHandler, + timeout?: number ) => Promise; getSenderCertificate: ( withUuid?: boolean @@ -4588,9 +4589,10 @@ export function initialize({ } function getProvisioningResource( - handler: IRequestHandler + handler: IRequestHandler, + timeout?: number ): Promise { - return socketManager.getProvisioningResource(handler); + return socketManager.getProvisioningResource(handler, timeout); } async function cdsLookup({ diff --git a/ts/types/InstallScreen.ts b/ts/types/InstallScreen.ts index b4678e5c4..f4ae78685 100644 --- a/ts/types/InstallScreen.ts +++ b/ts/types/InstallScreen.ts @@ -33,6 +33,7 @@ export enum InstallScreenError { } export enum InstallScreenQRCodeError { + MaxRotations = 'MaxRotations', Timeout = 'Timeout', Unknown = 'Unknown', NetworkIssue = 'NetworkIssue',