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",
|
"messageformat": "Retry",
|
||||||
"description": "Text of the button shown on the install screen if the QR code fails to load"
|
"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": {
|
"icu:Install__qr-failed-load__get-help": {
|
||||||
"messageformat": "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"
|
"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;
|
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 {
|
&__link {
|
||||||
@include mixins.button-reset;
|
@include mixins.button-reset;
|
||||||
& {
|
& {
|
||||||
|
|
|
@ -128,6 +128,17 @@ export function SimulatedLoading(): JSX.Element {
|
||||||
return <Simulation finalResult={LOADED_URL} />;
|
return <Simulation finalResult={LOADED_URL} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SimulatedMaxRotationsError(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Simulation
|
||||||
|
finalResult={{
|
||||||
|
loadingState: LoadingState.LoadFailed,
|
||||||
|
error: InstallScreenQRCodeError.MaxRotations,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SimulatedUnknownError(): JSX.Element {
|
export function SimulatedUnknownError(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Simulation
|
<Simulation
|
||||||
|
|
|
@ -132,6 +132,8 @@ function InstallScreenQrCode(
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
|
|
||||||
let contents: ReactNode;
|
let contents: ReactNode;
|
||||||
|
|
||||||
|
let isJustButton = false;
|
||||||
switch (props.loadingState) {
|
switch (props.loadingState) {
|
||||||
case LoadingState.Loading:
|
case LoadingState.Loading:
|
||||||
contents = <Spinner size="24px" svgSize="small" />;
|
contents = <Spinner size="24px" svgSize="small" />;
|
||||||
|
@ -146,7 +148,9 @@ function InstallScreenQrCode(
|
||||||
>
|
>
|
||||||
{i18n('icu:Install__qr-failed-load__error--timeout')}
|
{i18n('icu:Install__qr-failed-load__error--timeout')}
|
||||||
</span>
|
</span>
|
||||||
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
|
<RetryButton onClick={props.retryGetQrCode}>
|
||||||
|
{i18n('icu:Install__qr-failed-load__retry')}
|
||||||
|
</RetryButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
@ -162,7 +166,9 @@ function InstallScreenQrCode(
|
||||||
components={{ paragraph: Paragraph }}
|
components={{ paragraph: Paragraph }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<RetryButton i18n={i18n} onClick={props.retryGetQrCode} />
|
<RetryButton onClick={props.retryGetQrCode}>
|
||||||
|
{i18n('icu:Install__qr-failed-load__retry')}
|
||||||
|
</RetryButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
@ -186,6 +192,14 @@ function InstallScreenQrCode(
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case InstallScreenQRCodeError.MaxRotations:
|
||||||
|
isJustButton = true;
|
||||||
|
contents = (
|
||||||
|
<RetryButton onClick={props.retryGetQrCode}>
|
||||||
|
{i18n('icu:Install__qr-max-rotations__retry')}
|
||||||
|
</RetryButton>
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(props.error);
|
throw missingCaseError(props.error);
|
||||||
}
|
}
|
||||||
|
@ -210,7 +224,8 @@ function InstallScreenQrCode(
|
||||||
props.loadingState === LoadingState.Loaded &&
|
props.loadingState === LoadingState.Loaded &&
|
||||||
getQrCodeClassName('--loaded'),
|
getQrCodeClassName('--loaded'),
|
||||||
props.loadingState === LoadingState.LoadFailed &&
|
props.loadingState === LoadingState.LoadFailed &&
|
||||||
getQrCodeClassName('--load-failed')
|
getQrCodeClassName('--load-failed'),
|
||||||
|
isJustButton && getQrCodeClassName('--just-button')
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{contents}
|
{contents}
|
||||||
|
@ -219,11 +234,11 @@ function InstallScreenQrCode(
|
||||||
}
|
}
|
||||||
|
|
||||||
function RetryButton({
|
function RetryButton({
|
||||||
i18n,
|
|
||||||
onClick,
|
onClick,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
i18n: LocalizerType;
|
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(ev: React.KeyboardEvent<HTMLButtonElement>) => {
|
(ev: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
@ -243,7 +258,7 @@ function RetryButton({
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{i18n('icu:Install__qr-failed-load__retry')}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import type { ThunkAction } from 'redux-thunk';
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import pTimeout, { TimeoutError } from 'p-timeout';
|
|
||||||
|
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import {
|
import {
|
||||||
|
@ -17,13 +16,14 @@ import * as Errors from '../../types/errors';
|
||||||
import { type Loadable, LoadingState } from '../../util/loadable';
|
import { type Loadable, LoadingState } from '../../util/loadable';
|
||||||
import { isRecord } from '../../util/isRecord';
|
import { isRecord } from '../../util/isRecord';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { SECOND } from '../../util/durations';
|
|
||||||
import * as Registration from '../../util/registration';
|
import * as Registration from '../../util/registration';
|
||||||
import { isBackupEnabled } from '../../util/isBackupEnabled';
|
import { isBackupEnabled } from '../../util/isBackupEnabled';
|
||||||
import { HTTPError, InactiveTimeoutError } from '../../textsecure/Errors';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
|
import { HTTPError } from '../../textsecure/Errors';
|
||||||
import {
|
import {
|
||||||
Provisioner,
|
Provisioner,
|
||||||
type PrepareLinkDataOptionsType,
|
EventKind as ProvisionEventKind,
|
||||||
|
type EnvelopeType as ProvisionEnvelopeType,
|
||||||
} from '../../textsecure/Provisioner';
|
} from '../../textsecure/Provisioner';
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
@ -31,14 +31,10 @@ import * as log from '../../logging/log';
|
||||||
import { backupsService } from '../../services/backups';
|
import { backupsService } from '../../services/backups';
|
||||||
import OS from '../../util/os/osMain';
|
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 }>;
|
export type BatonType = ReadonlyDeep<{ __installer_baton: never }>;
|
||||||
|
|
||||||
const controllerByBaton = new WeakMap<BatonType, AbortController>();
|
const cancelByBaton = new WeakMap<BatonType, () => void>();
|
||||||
const provisionerByBaton = new WeakMap<BatonType, Provisioner>();
|
let provisioner: Provisioner | undefined;
|
||||||
|
|
||||||
export type InstallerStateType = ReadonlyDeep<
|
export type InstallerStateType = ReadonlyDeep<
|
||||||
| {
|
| {
|
||||||
|
@ -48,12 +44,12 @@ export type InstallerStateType = ReadonlyDeep<
|
||||||
step: InstallScreenStep.QrCodeNotScanned;
|
step: InstallScreenStep.QrCodeNotScanned;
|
||||||
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
|
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
|
||||||
baton: BatonType;
|
baton: BatonType;
|
||||||
attemptCount: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
step: InstallScreenStep.ChoosingDeviceName;
|
step: InstallScreenStep.ChoosingDeviceName;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
backupFile?: File;
|
backupFile?: File;
|
||||||
|
envelope: ProvisionEnvelopeType;
|
||||||
baton: BatonType;
|
baton: BatonType;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
@ -118,6 +114,7 @@ type QRCodeScannedActionType = ReadonlyDeep<{
|
||||||
payload: {
|
payload: {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
baton: BatonType;
|
baton: BatonType;
|
||||||
|
envelope: ProvisionEnvelopeType;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ -193,82 +190,49 @@ function startInstaller(): ThunkAction<
|
||||||
state.step === InstallScreenStep.QrCodeNotScanned,
|
state.step === InstallScreenStep.QrCodeNotScanned,
|
||||||
'Unexpected step after START_INSTALLER'
|
'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;
|
const { server } = window.textsecure;
|
||||||
strictAssert(server, 'Expected a server');
|
strictAssert(server, 'Expected a server');
|
||||||
|
|
||||||
const provisioner = new Provisioner({
|
if (!provisioner) {
|
||||||
server,
|
provisioner = new Provisioner({
|
||||||
appVersion: window.getVersion(),
|
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) {
|
const cancel = provisioner.subscribe(event => {
|
||||||
return;
|
if (event.kind === ProvisionEventKind.MaxRotationsError) {
|
||||||
}
|
log.warn('InstallScreen/getQRCode: max rotations reached');
|
||||||
|
|
||||||
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({
|
dispatch({
|
||||||
type: SET_QR_CODE_ERROR,
|
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 (
|
if (
|
||||||
|
error instanceof HTTPError &&
|
||||||
|
error.code === -1 &&
|
||||||
isRecord(error.cause) &&
|
isRecord(error.cause) &&
|
||||||
error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
|
error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
|
||||||
) {
|
) {
|
||||||
|
@ -278,89 +242,89 @@ function startInstaller(): ThunkAction<
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SET_ERROR,
|
type: SET_ERROR,
|
||||||
payload: InstallScreenError.ConnectionFailed,
|
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({
|
dispatch({
|
||||||
type: SET_ERROR,
|
type: SET_QR_CODE_ERROR,
|
||||||
payload: InstallScreenError.InactiveTimeout,
|
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({
|
if (event.isLinkAndSync) {
|
||||||
type: SET_ERROR,
|
const deviceName = OS.getName() || 'Signal Desktop';
|
||||||
payload: InstallScreenError.ConnectionFailed,
|
dispatch(
|
||||||
});
|
finishInstall({
|
||||||
return;
|
envelope,
|
||||||
}
|
deviceName,
|
||||||
|
isLinkAndSync: true,
|
||||||
if (signal.aborted) {
|
})
|
||||||
return;
|
);
|
||||||
}
|
} else {
|
||||||
provisionerByBaton.set(baton, provisioner);
|
const deviceName =
|
||||||
|
|
||||||
if (provisioner.isLinkAndSync()) {
|
|
||||||
dispatch(finishInstall({ deviceName: OS.getName() || 'Signal Desktop' }));
|
|
||||||
} else {
|
|
||||||
// Show screen to choose device name
|
|
||||||
dispatch({
|
|
||||||
type: QR_CODE_SCANNED,
|
|
||||||
payload: {
|
|
||||||
deviceName:
|
|
||||||
window.textsecure.storage.user.getDeviceName() ||
|
window.textsecure.storage.user.getDeviceName() ||
|
||||||
window.getHostName() ||
|
window.getHostName() ||
|
||||||
'',
|
'';
|
||||||
baton,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// And feed it the CI data if present
|
// Show screen to choose device name
|
||||||
const { SignalCI } = window;
|
dispatch({
|
||||||
if (SignalCI != null) {
|
type: QR_CODE_SCANNED,
|
||||||
dispatch(
|
payload: {
|
||||||
finishInstall({
|
deviceName,
|
||||||
deviceName: SignalCI.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(
|
type FinishInstallOptionsType = ReadonlyDeep<{
|
||||||
options: PrepareLinkDataOptionsType
|
isLinkAndSync: boolean;
|
||||||
): ThunkAction<
|
deviceName: string;
|
||||||
|
envelope?: ProvisionEnvelopeType;
|
||||||
|
backupFile?: Uint8Array;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function finishInstall({
|
||||||
|
isLinkAndSync,
|
||||||
|
envelope: providedEnvelope,
|
||||||
|
deviceName,
|
||||||
|
backupFile,
|
||||||
|
}: FinishInstallOptionsType): ThunkAction<
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
|
@ -371,42 +335,46 @@ function finishInstall(
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const state = 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(
|
strictAssert(
|
||||||
provisioner != null,
|
provisioner != null,
|
||||||
'Provisioner is not waiting for device info'
|
'Provisioner is not waiting for device info'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let envelope: ProvisionEnvelopeType;
|
||||||
if (state.installer.step === InstallScreenStep.QrCodeNotScanned) {
|
if (state.installer.step === InstallScreenStep.QrCodeNotScanned) {
|
||||||
|
strictAssert(isLinkAndSync, 'Can only skip device naming if link & sync');
|
||||||
strictAssert(
|
strictAssert(
|
||||||
provisioner.isLinkAndSync(),
|
providedEnvelope != null,
|
||||||
'Can only skip device naming if link & sync'
|
'finishInstall: missing required envelope'
|
||||||
);
|
);
|
||||||
|
envelope = providedEnvelope;
|
||||||
|
} else if (state.installer.step === InstallScreenStep.ChoosingDeviceName) {
|
||||||
|
({ envelope } = state.installer);
|
||||||
|
} else {
|
||||||
|
throw new Error('Wrong step');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
controllerByBaton.delete(baton);
|
const { baton } = state.installer;
|
||||||
provisionerByBaton.delete(baton);
|
cancelByBaton.delete(baton);
|
||||||
|
|
||||||
const accountManager = window.getAccountManager();
|
const accountManager = window.getAccountManager();
|
||||||
strictAssert(accountManager, 'Expected an account manager');
|
strictAssert(accountManager, 'Expected an account manager');
|
||||||
|
|
||||||
if (isBackupEnabled() || provisioner.isLinkAndSync()) {
|
if (isBackupEnabled() || isLinkAndSync) {
|
||||||
dispatch({ type: SHOW_BACKUP_IMPORT });
|
dispatch({ type: SHOW_BACKUP_IMPORT });
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: SHOW_LINK_IN_PROGRESS });
|
dispatch({ type: SHOW_LINK_IN_PROGRESS });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = provisioner.prepareLinkData(options);
|
await accountManager.registerSecondDevice(
|
||||||
await accountManager.registerSecondDevice(data);
|
Provisioner.prepareLinkData({
|
||||||
|
envelope,
|
||||||
|
deviceName,
|
||||||
|
backupFile,
|
||||||
|
})
|
||||||
|
);
|
||||||
window.IPC.removeSetupMenuItems();
|
window.IPC.removeSetupMenuItems();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
|
@ -498,8 +466,11 @@ export function reducer(
|
||||||
if (action.type === START_INSTALLER) {
|
if (action.type === START_INSTALLER) {
|
||||||
// Abort previous install
|
// Abort previous install
|
||||||
if (state.step === InstallScreenStep.QrCodeNotScanned) {
|
if (state.step === InstallScreenStep.QrCodeNotScanned) {
|
||||||
const controller = controllerByBaton.get(state.baton);
|
const cancel = cancelByBaton.get(state.baton);
|
||||||
controller?.abort();
|
cancel?.();
|
||||||
|
} else {
|
||||||
|
// Reset qr code fetch attempt count when starting from scratch
|
||||||
|
provisioner?.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -508,17 +479,15 @@ export function reducer(
|
||||||
loadingState: LoadingState.Loading,
|
loadingState: LoadingState.Loading,
|
||||||
},
|
},
|
||||||
baton: action.payload,
|
baton: action.payload,
|
||||||
attemptCount:
|
|
||||||
state.step === InstallScreenStep.QrCodeNotScanned
|
|
||||||
? state.attemptCount + 1
|
|
||||||
: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === SET_PROVISIONING_URL) {
|
if (action.type === SET_PROVISIONING_URL) {
|
||||||
if (
|
if (
|
||||||
state.step !== InstallScreenStep.QrCodeNotScanned ||
|
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);
|
log.warn('ducks/installer: not setting provisioning url', state.step);
|
||||||
return state;
|
return state;
|
||||||
|
@ -536,7 +505,11 @@ export function reducer(
|
||||||
if (action.type === SET_QR_CODE_ERROR) {
|
if (action.type === SET_QR_CODE_ERROR) {
|
||||||
if (
|
if (
|
||||||
state.step !== InstallScreenStep.QrCodeNotScanned ||
|
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);
|
log.warn('ducks/installer: not setting qr code error', state.step);
|
||||||
return state;
|
return state;
|
||||||
|
@ -570,6 +543,7 @@ export function reducer(
|
||||||
return {
|
return {
|
||||||
step: InstallScreenStep.ChoosingDeviceName,
|
step: InstallScreenStep.ChoosingDeviceName,
|
||||||
deviceName: action.payload.deviceName,
|
deviceName: action.payload.deviceName,
|
||||||
|
envelope: action.payload.envelope,
|
||||||
baton: action.payload.baton,
|
baton: action.payload.baton,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,9 +41,17 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
const onSubmitDeviceName = useCallback(async () => {
|
const onSubmitDeviceName = useCallback(async () => {
|
||||||
if (backupFile != null) {
|
if (backupFile != null) {
|
||||||
// This is only for testing so don't bother catching errors
|
// 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 {
|
} else {
|
||||||
finishInstall({ deviceName, backupFile: undefined });
|
finishInstall({
|
||||||
|
deviceName,
|
||||||
|
backupFile: undefined,
|
||||||
|
isLinkAndSync: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [backupFile, deviceName, finishInstall]);
|
}, [backupFile, deviceName, finishInstall]);
|
||||||
|
|
||||||
|
|
|
@ -315,9 +315,3 @@ export class IncorrectSenderKeyAuthError extends Error {}
|
||||||
export class WarnOnlyError extends Error {}
|
export class WarnOnlyError extends Error {}
|
||||||
|
|
||||||
export class NoSenderKeyError 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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import {
|
import pTimeout, { TimeoutError as PTimeoutError } from 'p-timeout';
|
||||||
type ExplodePromiseResultType,
|
|
||||||
explodePromise,
|
import * as log from '../logging/log';
|
||||||
} 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 * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
|
import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen';
|
||||||
import {
|
import {
|
||||||
isUntaggedPniString,
|
isUntaggedPniString,
|
||||||
normalizePni,
|
normalizePni,
|
||||||
toTaggedPni,
|
toTaggedPni,
|
||||||
} from '../types/ServiceId';
|
} 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 Bytes from '../Bytes';
|
||||||
import * as log from '../logging/log';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import { type WebAPIType } from './WebAPI';
|
|
||||||
import ProvisioningCipher, {
|
|
||||||
type ProvisionDecryptResult,
|
|
||||||
} from './ProvisioningCipher';
|
|
||||||
import {
|
import {
|
||||||
type CreateLinkedDeviceOptionsType,
|
type CreateLinkedDeviceOptionsType,
|
||||||
AccountType,
|
AccountType,
|
||||||
} from './AccountManager';
|
} from './AccountManager';
|
||||||
|
import ProvisioningCipher, {
|
||||||
|
type ProvisionDecryptResult,
|
||||||
|
} from './ProvisioningCipher';
|
||||||
import {
|
import {
|
||||||
type IWebSocketResource,
|
type IWebSocketResource,
|
||||||
type IncomingWebSocketRequest,
|
type IncomingWebSocketRequest,
|
||||||
ServerRequestType,
|
ServerRequestType,
|
||||||
} from './WebsocketResources';
|
} from './WebsocketResources';
|
||||||
import { InactiveTimeoutError } from './Errors';
|
import { ConnectTimeoutError } from './Errors';
|
||||||
|
import { type WebAPIType } from './WebAPI';
|
||||||
|
|
||||||
enum Step {
|
export enum EventKind {
|
||||||
Idle = 'Idle',
|
MaxRotationsError = 'MaxRotationsError',
|
||||||
Connecting = 'Connecting',
|
TimeoutError = 'TimeoutError',
|
||||||
WaitingForURL = 'WaitingForURL',
|
ConnectError = 'ConnectError',
|
||||||
WaitingForEnvelope = 'WaitingForEnvelope',
|
EnvelopeError = 'EnvelopeError',
|
||||||
ReadyToLink = 'ReadyToLink',
|
URL = 'URL',
|
||||||
Done = 'Done',
|
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<{
|
export type ProvisionerOptionsType = Readonly<{
|
||||||
server: WebAPIType;
|
server: WebAPIType;
|
||||||
appVersion: string;
|
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 {
|
export class Provisioner {
|
||||||
readonly #cipher = new ProvisioningCipher();
|
readonly #subscribers = new Set<SubscriberType>();
|
||||||
readonly #server: WebAPIType;
|
readonly #server: WebAPIType;
|
||||||
readonly #appVersion: string;
|
readonly #appVersion: string;
|
||||||
#state: StateType = { step: Step.Idle };
|
readonly #retryBackOff = new BackOff(FIBONACCI_TIMEOUTS);
|
||||||
#wsr: IWebSocketResource | undefined;
|
|
||||||
|
|
||||||
constructor(options: ProvisionerOptionsType) {
|
#sockets: Array<IWebSocketResource> = [];
|
||||||
this.#server = options.server;
|
#abortController: AbortController | undefined;
|
||||||
this.#appVersion = options.appVersion;
|
#attemptCount = 0;
|
||||||
|
#isRunning = false;
|
||||||
|
|
||||||
|
constructor({ server, appVersion }: ProvisionerOptionsType) {
|
||||||
|
this.#server = server;
|
||||||
|
this.#appVersion = appVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
public close(error = new Error('Provisioner closed')): void {
|
public subscribe(notify: SubscribeNotifierType): UnsubscribeFunctionType {
|
||||||
try {
|
const subscriber = { notify };
|
||||||
this.#wsr?.close();
|
|
||||||
} catch {
|
this.#subscribers.add(subscriber);
|
||||||
// Best effort
|
if (this.#subscribers.size === 1) {
|
||||||
|
this.#start();
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevState = this.#state;
|
return () => {
|
||||||
this.#state = { step: Step.Done };
|
this.#subscribers.delete(subscriber);
|
||||||
|
if (this.#subscribers.size === 0) {
|
||||||
if (prevState.step === Step.WaitingForURL) {
|
this.#stop('Cancel, no subscribers');
|
||||||
prevState.url.reject(error);
|
|
||||||
} else if (prevState.step === Step.WaitingForEnvelope) {
|
|
||||||
prevState.done.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
public reset(): void {
|
||||||
strictAssert(
|
this.#attemptCount = 0;
|
||||||
this.#state.step === Step.WaitingForEnvelope,
|
this.#retryBackOff.reset();
|
||||||
`Invalid state for waitForEnvelope: ${this.#state.step}`
|
|
||||||
);
|
|
||||||
await this.#state.done.promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public prepareLinkData({
|
public static prepareLinkData({
|
||||||
|
envelope,
|
||||||
deviceName,
|
deviceName,
|
||||||
backupFile,
|
backupFile,
|
||||||
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
|
}: 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 {
|
const {
|
||||||
number,
|
number,
|
||||||
provisioningCode,
|
provisioningCode,
|
||||||
|
@ -270,72 +215,263 @@ export class Provisioner {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public isLinkAndSync(): boolean {
|
//
|
||||||
strictAssert(
|
// Private
|
||||||
this.#state.step === Step.ReadyToLink,
|
//
|
||||||
`Invalid state for prepareLinkData: ${this.#state.step}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const { envelope } = this.#state;
|
#start(): void {
|
||||||
|
log.info('Provisioniner: starting');
|
||||||
|
|
||||||
return (
|
if (this.#abortController) {
|
||||||
isLinkAndSyncEnabled(this.#appVersion) &&
|
strictAssert(this.#isRunning, 'Must be running to have controller');
|
||||||
Bytes.isNotEmpty(envelope.ephemeralBackupKey)
|
this.#abortController.abort();
|
||||||
);
|
}
|
||||||
|
this.#abortController = new AbortController();
|
||||||
|
|
||||||
|
this.#isRunning = true;
|
||||||
|
|
||||||
|
drop(this.#loop(this.#abortController.signal));
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleRequest(request: IncomingWebSocketRequest): void {
|
#stop(reason: string): void {
|
||||||
const pubKey = this.#cipher.getPublicKey();
|
if (!this.#isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info(`Provisioniner: stopping, reason=${reason}`);
|
||||||
|
|
||||||
if (
|
this.#abortController?.abort();
|
||||||
request.requestType === ServerRequestType.ProvisioningAddress &&
|
this.#abortController = undefined;
|
||||||
request.body
|
this.#isRunning = false;
|
||||||
) {
|
}
|
||||||
strictAssert(
|
|
||||||
this.#state.step === Step.WaitingForURL,
|
|
||||||
`Unexpected provisioning address, state: ${this.#state}`
|
|
||||||
);
|
|
||||||
const prevState = this.#state;
|
|
||||||
this.#state = { step: Step.WaitingForEnvelope, done: explodePromise() };
|
|
||||||
|
|
||||||
const proto = Proto.ProvisioningUuid.decode(request.body);
|
async #loop(signal: AbortSignal): Promise<void> {
|
||||||
const { uuid } = proto;
|
let rotations = 0;
|
||||||
strictAssert(uuid, 'Provisioner.getURL: expected a UUID');
|
while (this.#subscribers.size > 0) {
|
||||||
|
const logId = `Provisioner.loop(${rotations})`;
|
||||||
|
|
||||||
const url = linkDeviceRoute
|
if (rotations >= MAX_ROTATIONS) {
|
||||||
.toAppUrl({
|
log.info(`${logId}: exceeded max rotation count`);
|
||||||
uuid,
|
|
||||||
pubKey: Bytes.toBase64(pubKey),
|
|
||||||
capabilities: isLinkAndSyncEnabled(this.#appVersion)
|
|
||||||
? ['backup']
|
|
||||||
: [],
|
|
||||||
})
|
|
||||||
.toString();
|
|
||||||
|
|
||||||
window.SignalCI?.setProvisioningURL(url);
|
this.#notify({
|
||||||
prevState.url.resolve(url);
|
kind: EventKind.MaxRotationsError,
|
||||||
|
});
|
||||||
|
|
||||||
request.respond(200, 'OK');
|
this.#stop('Max rotations reached');
|
||||||
} else if (
|
break;
|
||||||
request.requestType === ServerRequestType.ProvisioningMessage &&
|
}
|
||||||
request.body
|
|
||||||
) {
|
|
||||||
strictAssert(
|
|
||||||
this.#state.step === Step.WaitingForEnvelope,
|
|
||||||
`Unexpected provisioning address, state: ${this.#state}`
|
|
||||||
);
|
|
||||||
const prevState = this.#state;
|
|
||||||
|
|
||||||
const ciphertext = Proto.ProvisionEnvelope.decode(request.body);
|
let delay: number;
|
||||||
const message = this.#cipher.decrypt(ciphertext);
|
|
||||||
|
|
||||||
this.#state = { step: Step.ReadyToLink, envelope: message };
|
try {
|
||||||
request.respond(200, 'OK');
|
const sleepMs = QR_CODE_TIMEOUTS[this.#attemptCount];
|
||||||
this.#wsr?.close();
|
|
||||||
|
|
||||||
prevState.done.resolve();
|
// eslint-disable-next-line no-await-in-loop
|
||||||
} else {
|
await this.#connect(signal, sleepMs);
|
||||||
log.error('Unknown websocket message', request.requestType);
|
|
||||||
|
// 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<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(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -349,7 +349,8 @@ export class SocketManager extends EventListener {
|
||||||
|
|
||||||
// Creates new IWebSocketResource for AccountManager's provisioning
|
// Creates new IWebSocketResource for AccountManager's provisioning
|
||||||
public async getProvisioningResource(
|
public async getProvisioningResource(
|
||||||
handler: IRequestHandler
|
handler: IRequestHandler,
|
||||||
|
timeout?: number
|
||||||
): Promise<IWebSocketResource> {
|
): Promise<IWebSocketResource> {
|
||||||
if (this.#isRemotelyExpired) {
|
if (this.#isRemotelyExpired) {
|
||||||
throw new Error('Remotely expired, not connecting provisioning socket');
|
throw new Error('Remotely expired, not connecting provisioning socket');
|
||||||
|
@ -366,6 +367,10 @@ export class SocketManager extends EventListener {
|
||||||
},
|
},
|
||||||
keepalive: { path: '/v1/keepalive/provisioning' },
|
keepalive: { path: '/v1/keepalive/provisioning' },
|
||||||
},
|
},
|
||||||
|
extraHeaders: {
|
||||||
|
'x-signal-websocket-timeout': 'true',
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
}).getResult();
|
}).getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -704,6 +709,7 @@ export class SocketManager extends EventListener {
|
||||||
resourceOptions,
|
resourceOptions,
|
||||||
query = {},
|
query = {},
|
||||||
extraHeaders = {},
|
extraHeaders = {},
|
||||||
|
timeout,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
@ -711,6 +717,7 @@ export class SocketManager extends EventListener {
|
||||||
resourceOptions: WebSocketResourceOptions;
|
resourceOptions: WebSocketResourceOptions;
|
||||||
query?: Record<string, string>;
|
query?: Record<string, string>;
|
||||||
extraHeaders?: Record<string, string>;
|
extraHeaders?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
}): AbortableProcess<IWebSocketResource> {
|
}): AbortableProcess<IWebSocketResource> {
|
||||||
const queryWithDefaults = {
|
const queryWithDefaults = {
|
||||||
agent: 'OWD',
|
agent: 'OWD',
|
||||||
|
@ -728,6 +735,7 @@ export class SocketManager extends EventListener {
|
||||||
version,
|
version,
|
||||||
certificateAuthority: this.options.certificateAuthority,
|
certificateAuthority: this.options.certificateAuthority,
|
||||||
proxyAgent,
|
proxyAgent,
|
||||||
|
timeout,
|
||||||
|
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
|
|
||||||
|
|
|
@ -1401,7 +1401,8 @@ export type WebAPIType = {
|
||||||
userLanguages: ReadonlyArray<string>
|
userLanguages: ReadonlyArray<string>
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
getProvisioningResource: (
|
getProvisioningResource: (
|
||||||
handler: IRequestHandler
|
handler: IRequestHandler,
|
||||||
|
timeout?: number
|
||||||
) => Promise<IWebSocketResource>;
|
) => Promise<IWebSocketResource>;
|
||||||
getSenderCertificate: (
|
getSenderCertificate: (
|
||||||
withUuid?: boolean
|
withUuid?: boolean
|
||||||
|
@ -4588,9 +4589,10 @@ export function initialize({
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProvisioningResource(
|
function getProvisioningResource(
|
||||||
handler: IRequestHandler
|
handler: IRequestHandler,
|
||||||
|
timeout?: number
|
||||||
): Promise<IWebSocketResource> {
|
): Promise<IWebSocketResource> {
|
||||||
return socketManager.getProvisioningResource(handler);
|
return socketManager.getProvisioningResource(handler, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cdsLookup({
|
async function cdsLookup({
|
||||||
|
|
|
@ -33,6 +33,7 @@ export enum InstallScreenError {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InstallScreenQRCodeError {
|
export enum InstallScreenQRCodeError {
|
||||||
|
MaxRotations = 'MaxRotations',
|
||||||
Timeout = 'Timeout',
|
Timeout = 'Timeout',
|
||||||
Unknown = 'Unknown',
|
Unknown = 'Unknown',
|
||||||
NetworkIssue = 'NetworkIssue',
|
NetworkIssue = 'NetworkIssue',
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue