// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ComponentProps } from 'react'; import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import pTimeout, { TimeoutError } from 'p-timeout'; import { getIntl } from '../selectors/user'; import { getUpdatesState } from '../selectors/updates'; import { useUpdatesActions } from '../ducks/updates'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; import * as log from '../../logging/log'; import type { Loadable } from '../../util/loadable'; import { LoadingState } from '../../util/loadable'; import { assertDev } from '../../util/assert'; import { explodePromise } from '../../util/explodePromise'; import { missingCaseError } from '../../util/missingCaseError'; import * as Registration from '../../util/registration'; import { InstallScreen, InstallScreenStep, } from '../../components/InstallScreen'; import { InstallError } from '../../components/installScreen/InstallScreenErrorStep'; import { LoadError } from '../../components/installScreen/InstallScreenQrCodeNotScannedStep'; import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep'; import { WidthBreakpoint } from '../../components/_util'; import { HTTPError } from '../../textsecure/Errors'; import { isRecord } from '../../util/isRecord'; import type { ConfirmNumberResultType } from '../../textsecure/AccountManager'; import * as Errors from '../../types/errors'; import { normalizeDeviceName } from '../../util/normalizeDeviceName'; import OS from '../../util/os/osMain'; import { SECOND } from '../../util/durations'; import { BackOff } from '../../util/BackOff'; import { drop } from '../../util/drop'; import { SmartToastManager } from './ToastManager'; import { fileToBytes } from '../../util/fileToBytes'; type PropsType = ComponentProps; type StateType = | { step: InstallScreenStep.Error; error: InstallError; } | { step: InstallScreenStep.QrCodeNotScanned; provisioningUrl: Loadable; } | { step: InstallScreenStep.ChoosingDeviceName; deviceName: string; backupFile?: File; } | { step: InstallScreenStep.LinkInProgress; }; const INITIAL_STATE: StateType = { step: InstallScreenStep.QrCodeNotScanned, provisioningUrl: { loadingState: LoadingState.Loading }, }; const qrCodeBackOff = new BackOff([ 10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND, ]); function classifyError( err: unknown ): { installError: InstallError } | { loadError: LoadError } { if (err instanceof HTTPError) { switch (err.code) { case -1: if ( isRecord(err.cause) && err.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN' ) { return { loadError: LoadError.NetworkIssue }; } return { installError: InstallError.ConnectionFailed }; case 409: return { installError: InstallError.TooOld }; case 411: return { installError: InstallError.TooManyDevices }; default: return { loadError: LoadError.Unknown }; } } // AccountManager.registerSecondDevice uses this specific "websocket closed" error // message. if (isRecord(err) && err.message === 'websocket closed') { return { installError: InstallError.ConnectionFailed }; } return { loadError: LoadError.Unknown }; } export const SmartInstallScreen = memo(function SmartInstallScreen() { const i18n = useSelector(getIntl); const updates = useSelector(getUpdatesState); const { startUpdate } = useUpdatesActions(); const hasExpired = useSelector(hasExpiredSelector); const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise()); const chooseBackupFilePromiseWrapperRef = useRef(explodePromise()); const [state, setState] = useState(INITIAL_STATE); const [retryCounter, setRetryCounter] = useState(0); const setProvisioningUrl = useCallback( (value: string) => { setState(currentState => { if (currentState.step !== InstallScreenStep.QrCodeNotScanned) { return currentState; } return { ...currentState, provisioningUrl: { loadingState: LoadingState.Loaded, value, }, }; }); }, [setState] ); const onQrCodeScanned = useCallback(() => { setState(currentState => { if (currentState.step !== InstallScreenStep.QrCodeNotScanned) { return currentState; } return { step: InstallScreenStep.ChoosingDeviceName, deviceName: normalizeDeviceName( window.textsecure.storage.user.getDeviceName() || window.getHostName() || '' ).slice(0, MAX_DEVICE_NAME_LENGTH), }; }); }, [setState]); const setDeviceName = useCallback( (deviceName: string) => { setState(currentState => { if (currentState.step !== InstallScreenStep.ChoosingDeviceName) { return currentState; } return { ...currentState, deviceName, }; }); }, [setState] ); const setBackupFile = useCallback( (backupFile: File) => { setState(currentState => { if (currentState.step !== InstallScreenStep.ChoosingDeviceName) { return currentState; } return { ...currentState, backupFile, }; }); }, [setState] ); const onSubmitDeviceName = useCallback(() => { if (state.step !== InstallScreenStep.ChoosingDeviceName) { return; } let deviceName: string = normalizeDeviceName(state.deviceName); if (!deviceName.length) { // This should be impossible, but we have it here just in case. assertDev( false, 'Unexpected empty device name. Falling back to placeholder value' ); deviceName = i18n('icu:Install__choose-device-name__placeholder'); } chooseDeviceNamePromiseWrapperRef.current.resolve(deviceName); chooseBackupFilePromiseWrapperRef.current.resolve(state.backupFile); setState({ step: InstallScreenStep.LinkInProgress }); }, [state, i18n]); useEffect(() => { let hasCleanedUp = false; const qrCodeResolution = explodePromise(); const accountManager = window.getAccountManager(); assertDev(accountManager, 'Expected an account manager'); const updateProvisioningUrl = (value: string): void => { if (hasCleanedUp) { return; } qrCodeResolution.resolve(); setProvisioningUrl(value); }; const confirmNumber = async (): Promise => { if (hasCleanedUp) { throw new Error('Cannot confirm number; the component was unmounted'); } onQrCodeScanned(); let deviceName: string; let backupFileData: Uint8Array | undefined; if (window.SignalCI) { ({ deviceName, backupData: backupFileData } = window.SignalCI); } else { deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise; const backupFile = await chooseBackupFilePromiseWrapperRef.current.promise; backupFileData = backupFile ? await fileToBytes(backupFile) : undefined; } if (hasCleanedUp) { throw new Error('Cannot confirm number; the component was unmounted'); } // Delete all data from the database unless we're in the middle of a re-link. // Without this, the app restarts at certain times and can cause weird things to // happen, like data from a previous light import showing up after a new install. const shouldRetainData = Registration.everDone(); if (!shouldRetainData) { try { await window.textsecure.storage.protocol.removeAllData(); } catch (error) { log.error( 'confirmNumber: error clearing database', Errors.toLogFormat(error) ); } } if (hasCleanedUp) { throw new Error('Cannot confirm number; the component was unmounted'); } return { deviceName, backupFile: backupFileData }; }; async function getQRCode(): Promise { const sleepError = new TimeoutError(); try { const qrCodePromise = accountManager.registerSecondDevice( updateProvisioningUrl, confirmNumber ); const sleepMs = qrCodeBackOff.getAndIncrement(); log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`); await Promise.all([ pTimeout(qrCodeResolution.promise, sleepMs, sleepError), // Note that `registerSecondDevice` resolves once the registration // is fully complete and thus should not be subjected to a timeout. qrCodePromise, ]); window.IPC.removeSetupMenuItems(); } catch (error) { log.error( 'account.registerSecondDevice: got an error', Errors.toLogFormat(error) ); if (hasCleanedUp) { return; } if (qrCodeBackOff.isFull()) { log.error('InstallScreen/getQRCode: too many tries'); setState({ step: InstallScreenStep.Error, error: InstallError.QRCodeFailed, }); return; } if (error === sleepError) { setState({ step: InstallScreenStep.QrCodeNotScanned, provisioningUrl: { loadingState: LoadingState.LoadFailed, error: LoadError.Timeout, }, }); return; } const classifiedError = classifyError(error); if ('installError' in classifiedError) { setState({ step: InstallScreenStep.Error, error: classifiedError.installError, }); } else { setState({ step: InstallScreenStep.QrCodeNotScanned, provisioningUrl: { loadingState: LoadingState.LoadFailed, error: classifiedError.loadError, }, }); } } } drop(getQRCode()); return () => { hasCleanedUp = true; }; }, [setProvisioningUrl, retryCounter, onQrCodeScanned]); let props: PropsType; switch (state.step) { case InstallScreenStep.Error: props = { step: InstallScreenStep.Error, screenSpecificProps: { i18n, error: state.error, quit: () => window.IPC.shutdown(), tryAgain: () => { setRetryCounter(count => count + 1); setState(INITIAL_STATE); }, }, }; break; case InstallScreenStep.QrCodeNotScanned: props = { step: InstallScreenStep.QrCodeNotScanned, screenSpecificProps: { i18n, provisioningUrl: state.provisioningUrl, hasExpired, updates, currentVersion: window.getVersion(), startUpdate, retryGetQrCode: () => { setRetryCounter(count => count + 1); setState(INITIAL_STATE); }, OS: OS.getName(), }, }; break; case InstallScreenStep.ChoosingDeviceName: props = { step: InstallScreenStep.ChoosingDeviceName, screenSpecificProps: { i18n, deviceName: state.deviceName, setDeviceName, setBackupFile, onSubmit: onSubmitDeviceName, }, }; break; case InstallScreenStep.LinkInProgress: props = { step: InstallScreenStep.LinkInProgress, screenSpecificProps: { i18n }, }; break; default: throw missingCaseError(state); } return ( <> ); });