QR code rotation
This commit is contained in:
parent
f4e5b8c80e
commit
ba80d310d2
11 changed files with 579 additions and 404 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
& {
|
||||
|
|
|
@ -128,6 +128,17 @@ export function SimulatedLoading(): JSX.Element {
|
|||
return <Simulation finalResult={LOADED_URL} />;
|
||||
}
|
||||
|
||||
export function SimulatedMaxRotationsError(): JSX.Element {
|
||||
return (
|
||||
<Simulation
|
||||
finalResult={{
|
||||
loadingState: LoadingState.LoadFailed,
|
||||
error: InstallScreenQRCodeError.MaxRotations,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulatedUnknownError(): JSX.Element {
|
||||
return (
|
||||
<Simulation
|
||||
|
|
|
@ -132,6 +132,8 @@ function InstallScreenQrCode(
|
|||
const { i18n } = props;
|
||||
|
||||
let contents: ReactNode;
|
||||
|
||||
let isJustButton = false;
|
||||
switch (props.loadingState) {
|
||||
case LoadingState.Loading:
|
||||
contents = <Spinner size="24px" svgSize="small" />;
|
||||
|
@ -146,7 +148,9 @@ function InstallScreenQrCode(
|
|||
>
|
||||
{i18n('icu:Install__qr-failed-load__error--timeout')}
|
||||
</span>
|
||||
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
|
||||
<RetryButton onClick={props.retryGetQrCode}>
|
||||
{i18n('icu:Install__qr-failed-load__retry')}
|
||||
</RetryButton>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
@ -162,7 +166,9 @@ function InstallScreenQrCode(
|
|||
components={{ paragraph: Paragraph }}
|
||||
/>
|
||||
</span>
|
||||
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
|
||||
<RetryButton onClick={props.retryGetQrCode}>
|
||||
{i18n('icu:Install__qr-failed-load__retry')}
|
||||
</RetryButton>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
@ -186,6 +192,14 @@ function InstallScreenQrCode(
|
|||
</>
|
||||
);
|
||||
break;
|
||||
case InstallScreenQRCodeError.MaxRotations:
|
||||
isJustButton = true;
|
||||
contents = (
|
||||
<RetryButton onClick={props.retryGetQrCode}>
|
||||
{i18n('icu:Install__qr-max-rotations__retry')}
|
||||
</RetryButton>
|
||||
);
|
||||
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<HTMLButtonElement>) => {
|
||||
|
@ -243,7 +258,7 @@ function RetryButton({
|
|||
onKeyDown={onKeyDown}
|
||||
type="button"
|
||||
>
|
||||
{i18n('icu:Install__qr-failed-load__retry')}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<BatonType, AbortController>();
|
||||
const provisionerByBaton = new WeakMap<BatonType, Provisioner>();
|
||||
const cancelByBaton = new WeakMap<BatonType, () => void>();
|
||||
let provisioner: Provisioner | undefined;
|
||||
|
||||
export type InstallerStateType = ReadonlyDeep<
|
||||
| {
|
||||
|
@ -48,12 +44,12 @@ export type InstallerStateType = ReadonlyDeep<
|
|||
step: InstallScreenStep.QrCodeNotScanned;
|
||||
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
|
||||
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) {
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'Expected a server');
|
||||
|
||||
if (!provisioner) {
|
||||
provisioner = new Provisioner({
|
||||
server,
|
||||
appVersion: window.getVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
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.MaxRotations,
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
} catch (error) {
|
||||
provisioner.close();
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
} else if (event.kind === ProvisionEventKind.ConnectError) {
|
||||
const { error } = event;
|
||||
|
||||
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) {
|
||||
dispatch({
|
||||
type: SET_QR_CODE_ERROR,
|
||||
payload: InstallScreenQRCodeError.Timeout,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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,69 +242,51 @@ 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;
|
||||
}
|
||||
} 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;
|
||||
|
||||
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 (event.isLinkAndSync) {
|
||||
const deviceName = OS.getName() || 'Signal Desktop';
|
||||
dispatch(
|
||||
finishInstall({
|
||||
envelope,
|
||||
deviceName,
|
||||
isLinkAndSync: true,
|
||||
})
|
||||
);
|
||||
|
||||
if (error instanceof InactiveTimeoutError) {
|
||||
dispatch({
|
||||
type: SET_ERROR,
|
||||
payload: InstallScreenError.InactiveTimeout,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
const deviceName =
|
||||
window.textsecure.storage.user.getDeviceName() ||
|
||||
window.getHostName() ||
|
||||
'';
|
||||
|
||||
// Show screen to choose device name
|
||||
dispatch({
|
||||
type: QR_CODE_SCANNED,
|
||||
payload: {
|
||||
deviceName:
|
||||
window.textsecure.storage.user.getDeviceName() ||
|
||||
window.getHostName() ||
|
||||
'',
|
||||
deviceName,
|
||||
envelope,
|
||||
baton,
|
||||
},
|
||||
});
|
||||
|
@ -350,17 +296,35 @@ function startInstaller(): ThunkAction<
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<string>;
|
||||
}
|
||||
| {
|
||||
step: Step.WaitingForEnvelope;
|
||||
done: ExplodePromiseResultType<void>;
|
||||
}
|
||||
| {
|
||||
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<SubscriberType>();
|
||||
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<IWebSocketResource> = [];
|
||||
#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);
|
||||
return () => {
|
||||
this.#subscribers.delete(subscriber);
|
||||
if (this.#subscribers.size === 0) {
|
||||
this.#stop('Cancel, no subscribers');
|
||||
}
|
||||
}
|
||||
|
||||
public async getURL(): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
public reset(): void {
|
||||
this.#attemptCount = 0;
|
||||
this.#retryBackOff.reset();
|
||||
}
|
||||
|
||||
log.info(`provisioning socket closed. Code: ${code} Reason: ${reason}`);
|
||||
this.close(new Error('websocket closed'));
|
||||
});
|
||||
|
||||
return this.#state.url.promise;
|
||||
}
|
||||
|
||||
public async waitForEnvelope(): Promise<void> {
|
||||
strictAssert(
|
||||
this.#state.step === Step.WaitingForEnvelope,
|
||||
`Invalid state for waitForEnvelope: ${this.#state.step}`
|
||||
);
|
||||
await this.#state.done.promise;
|
||||
}
|
||||
|
||||
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
|
||||
//
|
||||
|
||||
#start(): void {
|
||||
log.info('Provisioniner: starting');
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#stop(reason: string): void {
|
||||
if (!this.#isRunning) {
|
||||
return;
|
||||
}
|
||||
log.info(`Provisioniner: stopping, reason=${reason}`);
|
||||
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = undefined;
|
||||
this.#isRunning = false;
|
||||
}
|
||||
|
||||
async #loop(signal: AbortSignal): Promise<void> {
|
||||
let rotations = 0;
|
||||
while (this.#subscribers.size > 0) {
|
||||
const logId = `Provisioner.loop(${rotations})`;
|
||||
|
||||
if (rotations >= MAX_ROTATIONS) {
|
||||
log.info(`${logId}: exceeded max rotation count`);
|
||||
|
||||
this.#notify({
|
||||
kind: EventKind.MaxRotationsError,
|
||||
});
|
||||
|
||||
this.#stop('Max rotations reached');
|
||||
break;
|
||||
}
|
||||
|
||||
let delay: number;
|
||||
|
||||
try {
|
||||
const sleepMs = QR_CODE_TIMEOUTS[this.#attemptCount];
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
const { envelope } = this.#state;
|
||||
this.#notify({
|
||||
kind: EventKind.TimeoutError,
|
||||
canRetry,
|
||||
});
|
||||
} else {
|
||||
this.#notify({
|
||||
kind: EventKind.ConnectError,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
isLinkAndSyncEnabled(this.#appVersion) &&
|
||||
Bytes.isNotEmpty(envelope.ephemeralBackupKey)
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
#handleRequest(request: IncomingWebSocketRequest): void {
|
||||
const pubKey = this.#cipher.getPublicKey();
|
||||
|
||||
if (
|
||||
request.requestType === ServerRequestType.ProvisioningAddress &&
|
||||
request.body
|
||||
) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sleep(delay, signal);
|
||||
} catch (error) {
|
||||
// Sleep aborted
|
||||
strictAssert(
|
||||
this.#state.step === Step.WaitingForURL,
|
||||
`Unexpected provisioning address, state: ${this.#state}`
|
||||
this.#subscribers.size === 0,
|
||||
'Aborted with active subscribers'
|
||||
);
|
||||
const prevState = this.#state;
|
||||
this.#state = { step: Step.WaitingForEnvelope, done: explodePromise() };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proto = Proto.ProvisioningUuid.decode(request.body);
|
||||
const { uuid } = proto;
|
||||
strictAssert(uuid, 'Provisioner.getURL: expected a UUID');
|
||||
async #connect(signal: AbortSignal, timeout: number): Promise<void> {
|
||||
const cipher = new ProvisioningCipher();
|
||||
|
||||
const uuidPromise = explodePromise<string>();
|
||||
|
||||
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(pubKey),
|
||||
capabilities: isLinkAndSyncEnabled(this.#appVersion)
|
||||
? ['backup']
|
||||
: [],
|
||||
pubKey: Bytes.toBase64(cipher.getPublicKey()),
|
||||
capabilities: isLinkAndSyncEnabled(this.#appVersion) ? ['backup'] : [],
|
||||
})
|
||||
.toString();
|
||||
|
||||
window.SignalCI?.setProvisioningURL(url);
|
||||
prevState.url.resolve(url);
|
||||
this.#notify({ kind: EventKind.URL, url });
|
||||
|
||||
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.#sockets.push(resource);
|
||||
|
||||
const ciphertext = Proto.ProvisionEnvelope.decode(request.body);
|
||||
const message = this.#cipher.decrypt(ciphertext);
|
||||
while (this.#sockets.length > MAX_OPEN_SOCKETS) {
|
||||
log.info('Provisioner: closing extra socket');
|
||||
this.#sockets.shift()?.close();
|
||||
}
|
||||
}
|
||||
|
||||
this.#state = { step: Step.ReadyToLink, envelope: message };
|
||||
request.respond(200, 'OK');
|
||||
this.#wsr?.close();
|
||||
#handleClose(
|
||||
resource: IWebSocketResource,
|
||||
state: SocketState,
|
||||
code: number,
|
||||
reason: string
|
||||
): void {
|
||||
log.info(`Provisioner: socket closed, code=${code}, reason=${reason}`);
|
||||
|
||||
prevState.done.resolve();
|
||||
} else {
|
||||
log.error('Unknown websocket message', request.requestType);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<IWebSocketResource> {
|
||||
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<string, string>;
|
||||
extraHeaders?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}): AbortableProcess<IWebSocketResource> {
|
||||
const queryWithDefaults = {
|
||||
agent: 'OWD',
|
||||
|
@ -728,6 +735,7 @@ export class SocketManager extends EventListener {
|
|||
version,
|
||||
certificateAuthority: this.options.certificateAuthority,
|
||||
proxyAgent,
|
||||
timeout,
|
||||
|
||||
extraHeaders,
|
||||
|
||||
|
|
|
@ -1401,7 +1401,8 @@ export type WebAPIType = {
|
|||
userLanguages: ReadonlyArray<string>
|
||||
) => Promise<unknown>;
|
||||
getProvisioningResource: (
|
||||
handler: IRequestHandler
|
||||
handler: IRequestHandler,
|
||||
timeout?: number
|
||||
) => Promise<IWebSocketResource>;
|
||||
getSenderCertificate: (
|
||||
withUuid?: boolean
|
||||
|
@ -4588,9 +4589,10 @@ export function initialize({
|
|||
}
|
||||
|
||||
function getProvisioningResource(
|
||||
handler: IRequestHandler
|
||||
handler: IRequestHandler,
|
||||
timeout?: number
|
||||
): Promise<IWebSocketResource> {
|
||||
return socketManager.getProvisioningResource(handler);
|
||||
return socketManager.getProvisioningResource(handler, timeout);
|
||||
}
|
||||
|
||||
async function cdsLookup({
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum InstallScreenError {
|
|||
}
|
||||
|
||||
export enum InstallScreenQRCodeError {
|
||||
MaxRotations = 'MaxRotations',
|
||||
Timeout = 'Timeout',
|
||||
Unknown = 'Unknown',
|
||||
NetworkIssue = 'NetworkIssue',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue