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',