signal-desktop/ts/state/ducks/installer.ts
2025-01-14 12:14:32 -08:00

624 lines
16 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import type { StateType as RootStateType } from '../reducer';
import {
type InstallScreenBackupError,
InstallScreenBackupStep,
InstallScreenStep,
InstallScreenError,
InstallScreenQRCodeError,
} from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
import { type Loadable, LoadingState } from '../../util/loadable';
import { isRecord } from '../../util/isRecord';
import { strictAssert } from '../../util/assert';
import * as Registration from '../../util/registration';
import { isBackupEnabled } from '../../util/isBackupEnabled';
import { missingCaseError } from '../../util/missingCaseError';
import { HTTPError } from '../../textsecure/Errors';
import {
Provisioner,
EventKind as ProvisionEventKind,
type EnvelopeType as ProvisionEnvelopeType,
} from '../../textsecure/Provisioner';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import * as log from '../../logging/log';
import { backupsService } from '../../services/backups';
import OS from '../../util/os/osMain';
export type BatonType = ReadonlyDeep<{ __installer_baton: never }>;
const cancelByBaton = new WeakMap<BatonType, () => void>();
let provisioner: Provisioner | undefined;
export type InstallerStateType = ReadonlyDeep<
| {
step: InstallScreenStep.NotStarted;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
baton: BatonType;
}
| {
step: InstallScreenStep.ChoosingDeviceName;
deviceName: string;
backupFile?: File;
envelope: ProvisionEnvelopeType;
baton: BatonType;
}
| {
step: InstallScreenStep.Error;
error: InstallScreenError;
}
| {
step: InstallScreenStep.LinkInProgress;
}
| ({
step: InstallScreenStep.BackupImport;
backupStep: InstallScreenBackupStep;
error?: InstallScreenBackupError;
} & (
| {
backupStep:
| InstallScreenBackupStep.Download
| InstallScreenBackupStep.Process;
currentBytes: number;
totalBytes: number;
}
| {
backupStep: InstallScreenBackupStep.WaitForBackup;
}
))
>;
export type RetryBackupImportValue = ReadonlyDeep<'retry' | 'cancel'>;
export const START_INSTALLER = 'installer/START_INSTALLER';
const SET_PROVISIONING_URL = 'installer/SET_PROVISIONING_URL';
const SET_QR_CODE_ERROR = 'installer/SET_QR_CODE_ERROR';
const SET_ERROR = 'installer/SET_ERROR';
const QR_CODE_SCANNED = 'installer/QR_CODE_SCANNED';
const RETRY_BACKUP_IMPORT = 'installer/RETRY_BACKUP_IMPORT';
const SHOW_LINK_IN_PROGRESS = 'installer/SHOW_LINK_IN_PROGRESS';
export const SHOW_BACKUP_IMPORT = 'installer/SHOW_BACKUP_IMPORT';
const UPDATE_BACKUP_IMPORT_PROGRESS = 'installer/UPDATE_BACKUP_IMPORT_PROGRESS';
export type StartInstallerActionType = ReadonlyDeep<{
type: typeof START_INSTALLER;
payload: BatonType;
}>;
type SetProvisioningUrlActionType = ReadonlyDeep<{
type: typeof SET_PROVISIONING_URL;
payload: string;
}>;
type SetQRCodeErrorActionType = ReadonlyDeep<{
type: typeof SET_QR_CODE_ERROR;
payload: InstallScreenQRCodeError;
}>;
type SetErrorActionType = ReadonlyDeep<{
type: typeof SET_ERROR;
payload: InstallScreenError;
}>;
type QRCodeScannedActionType = ReadonlyDeep<{
type: typeof QR_CODE_SCANNED;
payload: {
deviceName: string;
baton: BatonType;
envelope: ProvisionEnvelopeType;
};
}>;
type RetryBackupImportActionType = ReadonlyDeep<{
type: typeof RETRY_BACKUP_IMPORT;
}>;
type ShowLinkInProgressActionType = ReadonlyDeep<{
type: typeof SHOW_LINK_IN_PROGRESS;
}>;
export type ShowBackupImportActionType = ReadonlyDeep<{
type: typeof SHOW_BACKUP_IMPORT;
}>;
type UpdateBackupImportProgressActionType = ReadonlyDeep<{
type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
payload:
| {
backupStep: InstallScreenBackupStep;
currentBytes: number;
totalBytes: number;
}
| {
error: InstallScreenBackupError;
};
}>;
export type InstallerActionType = ReadonlyDeep<
| StartInstallerActionType
| SetProvisioningUrlActionType
| SetQRCodeErrorActionType
| SetErrorActionType
| QRCodeScannedActionType
| RetryBackupImportActionType
| ShowLinkInProgressActionType
| ShowBackupImportActionType
| UpdateBackupImportProgressActionType
>;
export const actions = {
startInstaller,
finishInstall,
updateBackupImportProgress,
retryBackupImport,
showBackupImport,
handleMissingBackup,
showLinkInProgress,
};
export const useInstallerActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function startInstaller(): ThunkAction<
void,
RootStateType,
unknown,
InstallerActionType
> {
return async (dispatch, getState) => {
// WeakMap key
const baton = {} as BatonType;
window.IPC.addSetupMenuItems();
dispatch({
type: START_INSTALLER,
payload: baton,
});
const { installer: state } = getState();
strictAssert(
state.step === InstallScreenStep.QrCodeNotScanned,
'Unexpected step after START_INSTALLER'
);
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,
});
}
} 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 &&
isRecord(error.cause) &&
error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
) {
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.NetworkIssue,
});
return;
}
dispatch({
type: SET_ERROR,
payload: InstallScreenError.ConnectionFailed,
});
} 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,
});
} 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 (event.isLinkAndSync) {
const deviceName = OS.getName() || 'Signal Desktop';
dispatch(
finishInstall({
envelope,
deviceName,
isLinkAndSync: true,
})
);
} else {
const deviceName =
window.textsecure.storage.user.getDeviceName() ||
window.getHostName() ||
'';
// Show screen to choose device name
dispatch({
type: QR_CODE_SCANNED,
payload: {
deviceName,
envelope,
baton,
},
});
// And feed it the CI data if present
const { SignalCI } = window;
if (SignalCI != null) {
dispatch(
finishInstall({
envelope,
deviceName: SignalCI.deviceName,
isLinkAndSync: false,
})
);
}
}
} else {
throw missingCaseError(event);
}
});
cancelByBaton.set(baton, cancel);
};
}
type FinishInstallOptionsType = ReadonlyDeep<{
isLinkAndSync: boolean;
deviceName: string;
envelope?: ProvisionEnvelopeType;
backupFile?: Uint8Array;
}>;
function finishInstall({
isLinkAndSync,
envelope: providedEnvelope,
deviceName,
backupFile,
}: FinishInstallOptionsType): ThunkAction<
void,
RootStateType,
unknown,
| SetQRCodeErrorActionType
| SetErrorActionType
| ShowLinkInProgressActionType
| ShowBackupImportActionType
> {
return async (dispatch, getState) => {
const state = getState();
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(
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
const { baton } = state.installer;
cancelByBaton.delete(baton);
const accountManager = window.getAccountManager();
strictAssert(accountManager, 'Expected an account manager');
if (isBackupEnabled() || isLinkAndSync) {
dispatch({ type: SHOW_BACKUP_IMPORT });
} else {
dispatch({ type: SHOW_LINK_IN_PROGRESS });
}
try {
await accountManager.registerSecondDevice(
Provisioner.prepareLinkData({
envelope,
deviceName,
backupFile,
})
);
window.IPC.removeSetupMenuItems();
} catch (error) {
if (error instanceof HTTPError) {
switch (error.code) {
case 409:
dispatch({
type: SET_ERROR,
payload: InstallScreenError.TooOld,
});
return;
case 411:
dispatch({
type: SET_ERROR,
payload: InstallScreenError.TooManyDevices,
});
return;
default:
break;
}
}
dispatch({
type: SET_QR_CODE_ERROR,
payload: InstallScreenQRCodeError.Unknown,
});
return;
}
// 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(
'installer/finishInstall: error clearing database',
Errors.toLogFormat(error)
);
}
}
};
}
function showBackupImport(): ShowBackupImportActionType {
return { type: SHOW_BACKUP_IMPORT };
}
function showLinkInProgress(): ShowLinkInProgressActionType {
return { type: SHOW_LINK_IN_PROGRESS };
}
function handleMissingBackup(): ShowLinkInProgressActionType {
// If backup is missing, go to normal link-in-progress view
return { type: SHOW_LINK_IN_PROGRESS };
}
function updateBackupImportProgress(
payload: UpdateBackupImportProgressActionType['payload']
): UpdateBackupImportProgressActionType {
return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload };
}
function retryBackupImport(): ThunkAction<
void,
RootStateType,
unknown,
RetryBackupImportActionType
> {
return dispatch => {
dispatch({ type: RETRY_BACKUP_IMPORT });
backupsService.retryDownload();
};
}
// Reducer
export function getEmptyState(): InstallerStateType {
return {
step: InstallScreenStep.NotStarted,
};
}
export function reducer(
state: Readonly<InstallerStateType> = getEmptyState(),
action: Readonly<InstallerActionType>
): InstallerStateType {
if (action.type === START_INSTALLER) {
// Abort previous install
if (state.step === InstallScreenStep.QrCodeNotScanned) {
const cancel = cancelByBaton.get(state.baton);
cancel?.();
} else {
// Reset qr code fetch attempt count when starting from scratch
provisioner?.reset();
}
return {
step: InstallScreenStep.QrCodeNotScanned,
provisioningUrl: {
loadingState: LoadingState.Loading,
},
baton: action.payload,
};
}
if (action.type === SET_PROVISIONING_URL) {
if (
state.step !== InstallScreenStep.QrCodeNotScanned ||
(state.provisioningUrl.loadingState !== LoadingState.Loading &&
// Rotating
state.provisioningUrl.loadingState !== LoadingState.Loaded)
) {
log.warn('ducks/installer: not setting provisioning url', state.step);
return state;
}
return {
...state,
provisioningUrl: {
loadingState: LoadingState.Loaded,
value: action.payload,
},
};
}
if (action.type === SET_QR_CODE_ERROR) {
if (
state.step !== InstallScreenStep.QrCodeNotScanned ||
!(
state.provisioningUrl.loadingState === LoadingState.Loading ||
// Rotating
state.provisioningUrl.loadingState === LoadingState.Loaded
)
) {
log.warn('ducks/installer: not setting qr code error', state.step);
return state;
}
return {
...state,
provisioningUrl: {
loadingState: LoadingState.LoadFailed,
error: action.payload,
},
};
}
if (action.type === SET_ERROR) {
return {
step: InstallScreenStep.Error,
error: action.payload,
};
}
if (action.type === QR_CODE_SCANNED) {
if (
state.step !== InstallScreenStep.QrCodeNotScanned ||
state.provisioningUrl.loadingState !== LoadingState.Loaded
) {
log.warn('ducks/installer: not setting qr code scanned', state.step);
return state;
}
return {
step: InstallScreenStep.ChoosingDeviceName,
deviceName: action.payload.deviceName,
envelope: action.payload.envelope,
baton: action.payload.baton,
};
}
if (action.type === SHOW_LINK_IN_PROGRESS) {
if (
// Backups not supported
state.step !== InstallScreenStep.ChoosingDeviceName &&
// No backup available
state.step !== InstallScreenStep.BackupImport
) {
log.warn('ducks/installer: not setting link in progress', state.step);
return state;
}
return {
step: InstallScreenStep.LinkInProgress,
};
}
if (action.type === SHOW_BACKUP_IMPORT) {
if (
// Downloading backup after linking
state.step !== InstallScreenStep.QrCodeNotScanned &&
// Restarting backup download on startup
state.step !== InstallScreenStep.NotStarted
) {
log.warn('ducks/installer: not setting backup import', state.step);
return state;
}
return {
step: InstallScreenStep.BackupImport,
backupStep: InstallScreenBackupStep.WaitForBackup,
};
}
if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) {
if (state.step !== InstallScreenStep.BackupImport) {
log.warn(
'ducks/installer: not updating backup import progress',
state.step
);
return state;
}
if ('error' in action.payload) {
return {
...state,
error: action.payload.error,
};
}
return {
...state,
backupStep: action.payload.backupStep,
currentBytes: action.payload.currentBytes,
totalBytes: action.payload.totalBytes,
};
}
if (action.type === RETRY_BACKUP_IMPORT) {
if (state.step !== InstallScreenStep.BackupImport) {
log.warn(
'ducks/installer: wrong step, not retrying backup import',
state.step
);
return state;
}
return {
...state,
error: undefined,
};
}
return state;
}