Make backup import UI part of install
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
parent
1ae77a32db
commit
438091b33a
26 changed files with 829 additions and 596 deletions
|
@ -125,6 +125,7 @@ import type { SendStateByConversationId } from './messages/MessageSendState';
|
||||||
import { SendStatus } from './messages/MessageSendState';
|
import { SendStatus } from './messages/MessageSendState';
|
||||||
import * as Stickers from './types/Stickers';
|
import * as Stickers from './types/Stickers';
|
||||||
import * as Errors from './types/errors';
|
import * as Errors from './types/errors';
|
||||||
|
import { InstallScreenStep } from './types/InstallScreen';
|
||||||
import { SignalService as Proto } from './protobuf';
|
import { SignalService as Proto } from './protobuf';
|
||||||
import {
|
import {
|
||||||
onRetryRequest,
|
onRetryRequest,
|
||||||
|
@ -1242,7 +1243,7 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
window.Whisper.events.on('setupAsNewDevice', () => {
|
window.Whisper.events.on('setupAsNewDevice', () => {
|
||||||
window.IPC.readyForUpdates();
|
window.IPC.readyForUpdates();
|
||||||
window.reduxActions.app.openInstaller();
|
window.reduxActions.installer.startInstaller();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.Whisper.events.on('setupAsStandalone', () => {
|
window.Whisper.events.on('setupAsStandalone', () => {
|
||||||
|
@ -1461,13 +1462,13 @@ export async function startApp(): Promise<void> {
|
||||||
if (isCoreDataValid && Registration.everDone()) {
|
if (isCoreDataValid && Registration.everDone()) {
|
||||||
drop(connect());
|
drop(connect());
|
||||||
if (window.storage.get('backupDownloadPath')) {
|
if (window.storage.get('backupDownloadPath')) {
|
||||||
window.reduxActions.app.openBackupImport();
|
window.reduxActions.installer.showBackupImport();
|
||||||
} else {
|
} else {
|
||||||
window.reduxActions.app.openInbox();
|
window.reduxActions.app.openInbox();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.IPC.readyForUpdates();
|
window.IPC.readyForUpdates();
|
||||||
window.reduxActions.app.openInstaller();
|
window.reduxActions.installer.startInstaller();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeWindowService } = window.SignalContext;
|
const { activeWindowService } = window.SignalContext;
|
||||||
|
@ -1518,6 +1519,8 @@ export async function startApp(): Promise<void> {
|
||||||
afterStart();
|
afterStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backupReady = explodePromise<void>();
|
||||||
|
|
||||||
function afterStart() {
|
function afterStart() {
|
||||||
strictAssert(messageReceiver, 'messageReceiver must be initialized');
|
strictAssert(messageReceiver, 'messageReceiver must be initialized');
|
||||||
strictAssert(server, 'server must be initialized');
|
strictAssert(server, 'server must be initialized');
|
||||||
|
@ -1553,13 +1556,17 @@ export async function startApp(): Promise<void> {
|
||||||
drop(messageReceiver?.drain());
|
drop(messageReceiver?.drain());
|
||||||
|
|
||||||
if (hasAppEverBeenRegistered) {
|
if (hasAppEverBeenRegistered) {
|
||||||
if (
|
const state = window.reduxStore.getState();
|
||||||
window.reduxStore.getState().app.appView === AppViewType.Installer
|
if (state.app.appView === AppViewType.Installer) {
|
||||||
) {
|
if (state.installer.step === InstallScreenStep.LinkInProgress) {
|
||||||
log.info(
|
log.info(
|
||||||
'background: offline, but app has been registered before; opening inbox'
|
'background: offline, but app has been registered before; opening inbox'
|
||||||
);
|
);
|
||||||
window.reduxActions.app.openInbox();
|
window.reduxActions.app.openInbox();
|
||||||
|
} else if (state.installer.step === InstallScreenStep.BackupImport) {
|
||||||
|
log.warn('background: offline, but app has needs to import backup');
|
||||||
|
// TODO: DESKTOP-7584
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasInitialLoadCompleted) {
|
if (!hasInitialLoadCompleted) {
|
||||||
|
@ -1580,44 +1587,43 @@ export async function startApp(): Promise<void> {
|
||||||
onOffline();
|
onOffline();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.storage.get('backupDownloadPath')) {
|
drop(downloadBackup());
|
||||||
log.info(
|
|
||||||
'background: not running storage service while downloading backup'
|
|
||||||
);
|
|
||||||
drop(downloadBackup());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.registerRequestHandler(messageReceiver);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBackup() {
|
async function downloadBackup() {
|
||||||
|
strictAssert(server != null, 'server must be initialized');
|
||||||
|
strictAssert(
|
||||||
|
messageReceiver != null,
|
||||||
|
'MessageReceiver must be initialized'
|
||||||
|
);
|
||||||
|
|
||||||
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
||||||
if (!backupDownloadPath) {
|
if (!backupDownloadPath) {
|
||||||
log.warn('No backup download path, cannot download backup');
|
log.warn('downloadBackup: no backup download path, skipping');
|
||||||
|
backupReady.resolve();
|
||||||
|
server.registerRequestHandler(messageReceiver);
|
||||||
|
drop(runStorageService());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteDownloadPath =
|
const absoluteDownloadPath =
|
||||||
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
|
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
|
||||||
log.info('downloadBackup: downloading to', absoluteDownloadPath);
|
log.info('downloadBackup: downloading...');
|
||||||
await backupsService.download(absoluteDownloadPath, {
|
const hasBackup = await backupsService.download(absoluteDownloadPath, {
|
||||||
onProgress: (currentBytes, totalBytes) => {
|
onProgress: (currentBytes, totalBytes) => {
|
||||||
window.reduxActions.app.updateBackupImportProgress({
|
window.reduxActions.installer.updateBackupImportProgress({
|
||||||
currentBytes,
|
currentBytes,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await window.storage.remove('backupDownloadPath');
|
await window.storage.remove('backupDownloadPath');
|
||||||
window.reduxActions.app.openInbox();
|
|
||||||
|
|
||||||
|
log.info(`downloadBackup: done, had backup=${hasBackup}`);
|
||||||
|
|
||||||
|
// Start storage service sync, etc
|
||||||
log.info('downloadBackup: processing websocket messages, storage service');
|
log.info('downloadBackup: processing websocket messages, storage service');
|
||||||
strictAssert(server != null, 'server must be initialized');
|
backupReady.resolve();
|
||||||
strictAssert(
|
|
||||||
messageReceiver != null,
|
|
||||||
'MessageReceiver must be initialized'
|
|
||||||
);
|
|
||||||
server.registerRequestHandler(messageReceiver);
|
server.registerRequestHandler(messageReceiver);
|
||||||
drop(runStorageService());
|
drop(runStorageService());
|
||||||
}
|
}
|
||||||
|
@ -1718,6 +1724,8 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
strictAssert(server !== undefined, 'WebAPI not connected');
|
strictAssert(server !== undefined, 'WebAPI not connected');
|
||||||
|
|
||||||
|
await backupReady.promise;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
connectPromise = explodePromise();
|
connectPromise = explodePromise();
|
||||||
// Reset the flag and update it below if needed
|
// Reset the flag and update it below if needed
|
||||||
|
@ -1923,9 +1931,8 @@ export async function startApp(): Promise<void> {
|
||||||
setIsInitialSync(false);
|
setIsInitialSync(false);
|
||||||
|
|
||||||
// Switch to inbox view even if contact sync is still running
|
// Switch to inbox view even if contact sync is still running
|
||||||
if (
|
const state = window.reduxStore.getState();
|
||||||
window.reduxStore.getState().app.appView === AppViewType.Installer
|
if (state.app.appView === AppViewType.Installer) {
|
||||||
) {
|
|
||||||
log.info('firstRun: opening inbox');
|
log.info('firstRun: opening inbox');
|
||||||
window.reduxActions.app.openInbox();
|
window.reduxActions.app.openInbox();
|
||||||
} else {
|
} else {
|
||||||
|
@ -1961,6 +1968,17 @@ export async function startApp(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('firstRun: done');
|
log.info('firstRun: done');
|
||||||
|
} else {
|
||||||
|
const state = window.reduxStore.getState();
|
||||||
|
if (
|
||||||
|
state.app.appView === AppViewType.Installer &&
|
||||||
|
state.installer.step === InstallScreenStep.BackupImport
|
||||||
|
) {
|
||||||
|
log.info('notFirstRun: opening inbox after backup import');
|
||||||
|
window.reduxActions.app.openInbox();
|
||||||
|
} else {
|
||||||
|
log.info('notFirstRun: not opening inbox');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.storage.onready(async () => {
|
window.storage.onready(async () => {
|
||||||
|
|
|
@ -8,17 +8,14 @@ import classNames from 'classnames';
|
||||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||||
import type { VerificationTransport } from '../types/VerificationTransport';
|
import type { VerificationTransport } from '../types/VerificationTransport';
|
||||||
import { ThemeType } from '../types/Util';
|
import { ThemeType } from '../types/Util';
|
||||||
import type { LocalizerType } from '../types/Util';
|
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { type AppStateType, AppViewType } from '../state/ducks/app';
|
import { type AppStateType, AppViewType } from '../state/ducks/app';
|
||||||
import { SmartInstallScreen } from '../state/smart/InstallScreen';
|
import { SmartInstallScreen } from '../state/smart/InstallScreen';
|
||||||
import { StandaloneRegistration } from './StandaloneRegistration';
|
import { StandaloneRegistration } from './StandaloneRegistration';
|
||||||
import { BackupImportScreen } from './BackupImportScreen';
|
|
||||||
import { usePageVisibility } from '../hooks/usePageVisibility';
|
import { usePageVisibility } from '../hooks/usePageVisibility';
|
||||||
import { useReducedMotion } from '../hooks/useReducedMotion';
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
i18n: LocalizerType;
|
|
||||||
state: AppStateType;
|
state: AppStateType;
|
||||||
openInbox: () => void;
|
openInbox: () => void;
|
||||||
getCaptchaToken: () => Promise<string>;
|
getCaptchaToken: () => Promise<string>;
|
||||||
|
@ -53,7 +50,6 @@ type PropsType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function App({
|
export function App({
|
||||||
i18n,
|
|
||||||
state,
|
state,
|
||||||
getCaptchaToken,
|
getCaptchaToken,
|
||||||
hasSelectedStoryData,
|
hasSelectedStoryData,
|
||||||
|
@ -96,10 +92,8 @@ export function App({
|
||||||
contents = renderInbox();
|
contents = renderInbox();
|
||||||
} else if (state.appView === AppViewType.Blank) {
|
} else if (state.appView === AppViewType.Blank) {
|
||||||
contents = undefined;
|
contents = undefined;
|
||||||
} else if (state.appView === AppViewType.BackupImport) {
|
|
||||||
contents = <BackupImportScreen i18n={i18n} {...state} />;
|
|
||||||
} else {
|
} else {
|
||||||
throw missingCaseError(state);
|
throw missingCaseError(state.appView);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This are here so that themes are properly applied to anything that is
|
// This are here so that themes are properly applied to anything that is
|
||||||
|
|
|
@ -5,26 +5,17 @@ import type { ComponentProps, ReactElement } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
import { InstallScreenStep } from '../types/InstallScreen';
|
||||||
import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep';
|
import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep';
|
||||||
import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep';
|
import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep';
|
||||||
import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep';
|
import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep';
|
||||||
import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep';
|
import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep';
|
||||||
|
import { InstallScreenBackupImportStep } from './installScreen/InstallScreenBackupImportStep';
|
||||||
export enum InstallScreenStep {
|
|
||||||
Error,
|
|
||||||
QrCodeNotScanned,
|
|
||||||
ChoosingDeviceName,
|
|
||||||
LinkInProgress,
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't always use destructuring assignment because of the complexity of this props
|
// We can't always use destructuring assignment because of the complexity of this props
|
||||||
// type.
|
// type.
|
||||||
|
|
||||||
type PropsType =
|
type PropsType =
|
||||||
| {
|
|
||||||
step: InstallScreenStep.Error;
|
|
||||||
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
step: InstallScreenStep.QrCodeNotScanned;
|
step: InstallScreenStep.QrCodeNotScanned;
|
||||||
screenSpecificProps: ComponentProps<
|
screenSpecificProps: ComponentProps<
|
||||||
|
@ -42,6 +33,14 @@ type PropsType =
|
||||||
screenSpecificProps: ComponentProps<
|
screenSpecificProps: ComponentProps<
|
||||||
typeof InstallScreenLinkInProgressStep
|
typeof InstallScreenLinkInProgressStep
|
||||||
>;
|
>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.BackupImport;
|
||||||
|
screenSpecificProps: ComponentProps<typeof InstallScreenBackupImportStep>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.Error;
|
||||||
|
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InstallScreen(props: Readonly<PropsType>): ReactElement {
|
export function InstallScreen(props: Readonly<PropsType>): ReactElement {
|
||||||
|
@ -58,6 +57,8 @@ export function InstallScreen(props: Readonly<PropsType>): ReactElement {
|
||||||
);
|
);
|
||||||
case InstallScreenStep.LinkInProgress:
|
case InstallScreenStep.LinkInProgress:
|
||||||
return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />;
|
return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />;
|
||||||
|
case InstallScreenStep.BackupImport:
|
||||||
|
return <InstallScreenBackupImportStep {...props.screenSpecificProps} />;
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(props);
|
throw missingCaseError(props);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,20 +3,20 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Meta, StoryFn } from '@storybook/react';
|
import type { Meta, StoryFn } from '@storybook/react';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { PropsType } from './BackupImportScreen';
|
import type { PropsType } from './InstallScreenBackupImportStep';
|
||||||
import { BackupImportScreen } from './BackupImportScreen';
|
import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/BackupImportScreen',
|
title: 'Components/InstallScreenBackupImportStep',
|
||||||
} satisfies Meta<PropsType>;
|
} satisfies Meta<PropsType>;
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
const Template: StoryFn<PropsType> = (args: PropsType) => (
|
const Template: StoryFn<PropsType> = (args: PropsType) => (
|
||||||
<BackupImportScreen {...args} i18n={i18n} />
|
<InstallScreenBackupImportStep {...args} i18n={i18n} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NoBytes = Template.bind({});
|
export const NoBytes = Template.bind({});
|
|
@ -3,11 +3,11 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import { formatFileSize } from '../util/formatFileSize';
|
import { formatFileSize } from '../../util/formatFileSize';
|
||||||
import { TitlebarDragArea } from './TitlebarDragArea';
|
import { TitlebarDragArea } from '../TitlebarDragArea';
|
||||||
import { InstallScreenSignalLogo } from './installScreen/InstallScreenSignalLogo';
|
import { ProgressBar } from '../ProgressBar';
|
||||||
import { ProgressBar } from './ProgressBar';
|
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
||||||
|
|
||||||
// We can't always use destructuring assignment because of the complexity of this props
|
// We can't always use destructuring assignment because of the complexity of this props
|
||||||
// type.
|
// type.
|
||||||
|
@ -18,7 +18,7 @@ export type PropsType = Readonly<{
|
||||||
totalBytes?: number;
|
totalBytes?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function BackupImportScreen({
|
export function InstallScreenBackupImportStep({
|
||||||
i18n,
|
i18n,
|
||||||
currentBytes,
|
currentBytes,
|
||||||
totalBytes,
|
totalBytes,
|
|
@ -5,6 +5,7 @@ import type { ReactElement } from 'react';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import { MAX_DEVICE_NAME_LENGTH } from '../../types/InstallScreen';
|
||||||
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
|
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
|
||||||
import { getEnvironment, Environment } from '../../environment';
|
import { getEnvironment, Environment } from '../../environment';
|
||||||
|
|
||||||
|
@ -12,11 +13,6 @@ import { Button, ButtonVariant } from '../Button';
|
||||||
import { TitlebarDragArea } from '../TitlebarDragArea';
|
import { TitlebarDragArea } from '../TitlebarDragArea';
|
||||||
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
||||||
|
|
||||||
// This is the string's `.length`, which is the number of UTF-16 code points. Instead, we
|
|
||||||
// want this to be either 50 graphemes or 256 encrypted bytes, whichever is smaller. See
|
|
||||||
// DESKTOP-2844.
|
|
||||||
export const MAX_DEVICE_NAME_LENGTH = 50;
|
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
|
|
@ -5,9 +5,10 @@ import React from 'react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import type { Meta } from '@storybook/react';
|
import type { Meta } from '@storybook/react';
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
|
import { InstallScreenError } from '../../types/InstallScreen';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { Props } from './InstallScreenErrorStep';
|
import type { Props } from './InstallScreenErrorStep';
|
||||||
import { InstallScreenErrorStep, InstallError } from './InstallScreenErrorStep';
|
import { InstallScreenErrorStep } from './InstallScreenErrorStep';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -24,21 +25,21 @@ const defaultProps = {
|
||||||
export const _TooManyDevices = (): JSX.Element => (
|
export const _TooManyDevices = (): JSX.Element => (
|
||||||
<InstallScreenErrorStep
|
<InstallScreenErrorStep
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
error={InstallError.TooManyDevices}
|
error={InstallScreenError.TooManyDevices}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const _TooOld = (): JSX.Element => (
|
export const _TooOld = (): JSX.Element => (
|
||||||
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
|
<InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const __TooOld = (): JSX.Element => (
|
export const __TooOld = (): JSX.Element => (
|
||||||
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
|
<InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const _ConnectionFailed = (): JSX.Element => (
|
export const _ConnectionFailed = (): JSX.Element => (
|
||||||
<InstallScreenErrorStep
|
<InstallScreenErrorStep
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
error={InstallError.ConnectionFailed}
|
error={InstallScreenError.ConnectionFailed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,16 +10,10 @@ import { Button, ButtonVariant } from '../Button';
|
||||||
import { TitlebarDragArea } from '../TitlebarDragArea';
|
import { TitlebarDragArea } from '../TitlebarDragArea';
|
||||||
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
||||||
import { LINK_SIGNAL_DESKTOP } from '../../types/support';
|
import { LINK_SIGNAL_DESKTOP } from '../../types/support';
|
||||||
|
import { InstallScreenError } from '../../types/InstallScreen';
|
||||||
export enum InstallError {
|
|
||||||
TooManyDevices,
|
|
||||||
TooOld,
|
|
||||||
ConnectionFailed,
|
|
||||||
QRCodeFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Props = Readonly<{
|
export type Props = Readonly<{
|
||||||
error: InstallError;
|
error: InstallScreenError;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
quit: () => unknown;
|
quit: () => unknown;
|
||||||
tryAgain: () => unknown;
|
tryAgain: () => unknown;
|
||||||
|
@ -37,10 +31,10 @@ export function InstallScreenErrorStep({
|
||||||
let shouldShowQuitButton = false;
|
let shouldShowQuitButton = false;
|
||||||
|
|
||||||
switch (error) {
|
switch (error) {
|
||||||
case InstallError.TooManyDevices:
|
case InstallScreenError.TooManyDevices:
|
||||||
errorMessage = i18n('icu:installTooManyDevices');
|
errorMessage = i18n('icu:installTooManyDevices');
|
||||||
break;
|
break;
|
||||||
case InstallError.TooOld:
|
case InstallScreenError.TooOld:
|
||||||
errorMessage = i18n('icu:installTooOld');
|
errorMessage = i18n('icu:installTooOld');
|
||||||
buttonText = i18n('icu:upgrade');
|
buttonText = i18n('icu:upgrade');
|
||||||
onClickButton = () => {
|
onClickButton = () => {
|
||||||
|
@ -48,10 +42,10 @@ export function InstallScreenErrorStep({
|
||||||
};
|
};
|
||||||
shouldShowQuitButton = true;
|
shouldShowQuitButton = true;
|
||||||
break;
|
break;
|
||||||
case InstallError.ConnectionFailed:
|
case InstallScreenError.ConnectionFailed:
|
||||||
errorMessage = i18n('icu:installConnectionFailed');
|
errorMessage = i18n('icu:installConnectionFailed');
|
||||||
break;
|
break;
|
||||||
case InstallError.QRCodeFailed:
|
case InstallScreenError.QRCodeFailed:
|
||||||
buttonText = i18n('icu:Install__learn-more');
|
buttonText = i18n('icu:Install__learn-more');
|
||||||
errorMessage = i18n('icu:installUnknownError');
|
errorMessage = i18n('icu:installUnknownError');
|
||||||
onClickButton = () => {
|
onClickButton = () => {
|
||||||
|
|
|
@ -6,14 +6,12 @@ import { action } from '@storybook/addon-actions';
|
||||||
import type { Meta, StoryFn } from '@storybook/react';
|
import type { Meta, StoryFn } from '@storybook/react';
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import { DialogType } from '../../types/Dialogs';
|
import { DialogType } from '../../types/Dialogs';
|
||||||
|
import { InstallScreenQRCodeError } from '../../types/InstallScreen';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { Loadable } from '../../util/loadable';
|
import type { Loadable } from '../../util/loadable';
|
||||||
import { LoadingState } from '../../util/loadable';
|
import { LoadingState } from '../../util/loadable';
|
||||||
import type { PropsType } from './InstallScreenQrCodeNotScannedStep';
|
import type { PropsType } from './InstallScreenQrCodeNotScannedStep';
|
||||||
import {
|
import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep';
|
||||||
InstallScreenQrCodeNotScannedStep,
|
|
||||||
LoadError,
|
|
||||||
} from './InstallScreenQrCodeNotScannedStep';
|
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -40,10 +38,10 @@ export default {
|
||||||
function Simulation({
|
function Simulation({
|
||||||
finalResult,
|
finalResult,
|
||||||
}: {
|
}: {
|
||||||
finalResult: Loadable<string, LoadError>;
|
finalResult: Loadable<string, InstallScreenQRCodeError>;
|
||||||
}) {
|
}) {
|
||||||
const [provisioningUrl, setProvisioningUrl] = useState<
|
const [provisioningUrl, setProvisioningUrl] = useState<
|
||||||
Loadable<string, LoadError>
|
Loadable<string, InstallScreenQRCodeError>
|
||||||
>({
|
>({
|
||||||
loadingState: LoadingState.Loading,
|
loadingState: LoadingState.Loading,
|
||||||
});
|
});
|
||||||
|
@ -92,7 +90,7 @@ export function QrCodeFailedToLoad(): JSX.Element {
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
provisioningUrl={{
|
provisioningUrl={{
|
||||||
loadingState: LoadingState.LoadFailed,
|
loadingState: LoadingState.LoadFailed,
|
||||||
error: LoadError.Unknown,
|
error: InstallScreenQRCodeError.Unknown,
|
||||||
}}
|
}}
|
||||||
updates={DEFAULT_UPDATES}
|
updates={DEFAULT_UPDATES}
|
||||||
OS="macOS"
|
OS="macOS"
|
||||||
|
@ -126,7 +124,7 @@ export function SimulatedUnknownError(): JSX.Element {
|
||||||
<Simulation
|
<Simulation
|
||||||
finalResult={{
|
finalResult={{
|
||||||
loadingState: LoadingState.LoadFailed,
|
loadingState: LoadingState.LoadFailed,
|
||||||
error: LoadError.Unknown,
|
error: InstallScreenQRCodeError.Unknown,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -137,7 +135,7 @@ export function SimulatedNetworkIssue(): JSX.Element {
|
||||||
<Simulation
|
<Simulation
|
||||||
finalResult={{
|
finalResult={{
|
||||||
loadingState: LoadingState.LoadFailed,
|
loadingState: LoadingState.LoadFailed,
|
||||||
error: LoadError.NetworkIssue,
|
error: InstallScreenQRCodeError.NetworkIssue,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -148,7 +146,7 @@ export function SimulatedTimeout(): JSX.Element {
|
||||||
<Simulation
|
<Simulation
|
||||||
finalResult={{
|
finalResult={{
|
||||||
loadingState: LoadingState.LoadFailed,
|
loadingState: LoadingState.LoadFailed,
|
||||||
error: LoadError.Timeout,
|
error: InstallScreenQRCodeError.Timeout,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,6 +6,7 @@ import React, { useCallback } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import { InstallScreenQRCodeError } from '../../types/InstallScreen';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import type { Loadable } from '../../util/loadable';
|
import type { Loadable } from '../../util/loadable';
|
||||||
import { LoadingState } from '../../util/loadable';
|
import { LoadingState } from '../../util/loadable';
|
||||||
|
@ -20,18 +21,12 @@ import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||||
import type { UpdatesStateType } from '../../state/ducks/updates';
|
import type { UpdatesStateType } from '../../state/ducks/updates';
|
||||||
import { Environment, getEnvironment } from '../../environment';
|
import { Environment, getEnvironment } from '../../environment';
|
||||||
|
|
||||||
export enum LoadError {
|
|
||||||
Timeout = 'Timeout',
|
|
||||||
Unknown = 'Unknown',
|
|
||||||
NetworkIssue = 'NetworkIssue',
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't always use destructuring assignment because of the complexity of this props
|
// We can't always use destructuring assignment because of the complexity of this props
|
||||||
// type.
|
// type.
|
||||||
|
|
||||||
export type PropsType = Readonly<{
|
export type PropsType = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
provisioningUrl: Loadable<string, LoadError>;
|
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
|
||||||
hasExpired?: boolean;
|
hasExpired?: boolean;
|
||||||
updates: UpdatesStateType;
|
updates: UpdatesStateType;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
|
@ -121,7 +116,7 @@ export function InstallScreenQrCodeNotScannedStep({
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstallScreenQrCode(
|
function InstallScreenQrCode(
|
||||||
props: Loadable<string, LoadError> & {
|
props: Loadable<string, InstallScreenQRCodeError> & {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
retryGetQrCode: () => void;
|
retryGetQrCode: () => void;
|
||||||
}
|
}
|
||||||
|
@ -135,7 +130,7 @@ function InstallScreenQrCode(
|
||||||
break;
|
break;
|
||||||
case LoadingState.LoadFailed:
|
case LoadingState.LoadFailed:
|
||||||
switch (props.error) {
|
switch (props.error) {
|
||||||
case LoadError.Timeout:
|
case InstallScreenQRCodeError.Timeout:
|
||||||
contents = (
|
contents = (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
|
@ -147,7 +142,7 @@ function InstallScreenQrCode(
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case LoadError.Unknown:
|
case InstallScreenQRCodeError.Unknown:
|
||||||
contents = (
|
contents = (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
|
@ -163,7 +158,7 @@ function InstallScreenQrCode(
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case LoadError.NetworkIssue:
|
case InstallScreenQRCodeError.NetworkIssue:
|
||||||
contents = (
|
contents = (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { isNumber } from 'lodash';
|
||||||
import { CallLinkRootKey } from '@signalapp/ringrtc';
|
import { CallLinkRootKey } from '@signalapp/ringrtc';
|
||||||
|
|
||||||
import { Backups, SignalService } from '../../protobuf';
|
import { Backups, SignalService } from '../../protobuf';
|
||||||
import { DataWriter } from '../../sql/Client';
|
import { DataReader, DataWriter } from '../../sql/Client';
|
||||||
import {
|
import {
|
||||||
AttachmentDownloadSource,
|
AttachmentDownloadSource,
|
||||||
type StoryDistributionWithMembersType,
|
type StoryDistributionWithMembersType,
|
||||||
|
@ -78,7 +78,7 @@ import type { GroupV2ChangeDetailType } from '../../groups';
|
||||||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||||
import { drop } from '../../util/drop';
|
import { drop } from '../../util/drop';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation';
|
import { isGroup } from '../../util/whatTypeOfConversation';
|
||||||
import { rgbToHSL } from '../../util/rgbToHSL';
|
import { rgbToHSL } from '../../util/rgbToHSL';
|
||||||
import {
|
import {
|
||||||
convertBackupMessageAttachmentToAttachment,
|
convertBackupMessageAttachmentToAttachment,
|
||||||
|
@ -88,6 +88,7 @@ import { filterAndClean } from '../../types/BodyRange';
|
||||||
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
|
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
|
||||||
import { copyFromQuotedMessage } from '../../messages/copyQuote';
|
import { copyFromQuotedMessage } from '../../messages/copyQuote';
|
||||||
import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue';
|
import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue';
|
||||||
|
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
|
||||||
import {
|
import {
|
||||||
AdhocCallStatus,
|
AdhocCallStatus,
|
||||||
CallDirection,
|
CallDirection,
|
||||||
|
@ -99,9 +100,10 @@ import {
|
||||||
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
||||||
import { CallLinkRestrictions } from '../../types/CallLink';
|
import { CallLinkRestrictions } from '../../types/CallLink';
|
||||||
import type { CallLinkType } from '../../types/CallLink';
|
import type { CallLinkType } from '../../types/CallLink';
|
||||||
|
|
||||||
import { fromAdminKeyBytes } from '../../util/callLinks';
|
import { fromAdminKeyBytes } from '../../util/callLinks';
|
||||||
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
|
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
|
||||||
|
import { reinitializeRedux } from '../../state/reinitializeRedux';
|
||||||
|
import { getParametersForRedux, loadAll } from '../allLoaders';
|
||||||
|
|
||||||
const MAX_CONCURRENCY = 10;
|
const MAX_CONCURRENCY = 10;
|
||||||
|
|
||||||
|
@ -283,11 +285,21 @@ export class BackupImportStream extends Writable {
|
||||||
private customColorById = new Map<number, CustomColorDataType>();
|
private customColorById = new Map<number, CustomColorDataType>();
|
||||||
private releaseNotesRecipientId: Long | undefined;
|
private releaseNotesRecipientId: Long | undefined;
|
||||||
private releaseNotesChatId: Long | undefined;
|
private releaseNotesChatId: Long | undefined;
|
||||||
|
private pendingGroupAvatars = new Map<string, string>();
|
||||||
|
|
||||||
constructor() {
|
private constructor() {
|
||||||
super({ objectMode: true });
|
super({ objectMode: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async create(): Promise<BackupImportStream> {
|
||||||
|
await AttachmentDownloadManager.stop();
|
||||||
|
await DataWriter.removeAllBackupAttachmentDownloadJobs();
|
||||||
|
await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
|
||||||
|
await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
|
||||||
|
|
||||||
|
return new BackupImportStream();
|
||||||
|
}
|
||||||
|
|
||||||
override async _write(
|
override async _write(
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
_enc: BufferEncoding,
|
_enc: BufferEncoding,
|
||||||
|
@ -359,11 +371,10 @@ export class BackupImportStream extends Writable {
|
||||||
|
|
||||||
// Schedule group avatar download.
|
// Schedule group avatar download.
|
||||||
await pMap(
|
await pMap(
|
||||||
allConversations.filter(({ attributes: convo }) => {
|
[...this.pendingGroupAvatars.entries()],
|
||||||
const { avatar } = convo;
|
([conversationId, newAvatarUrl]) => {
|
||||||
return isGroupV2(convo) && avatar?.url && !avatar.path;
|
return groupAvatarJobQueue.add({ conversationId, newAvatarUrl });
|
||||||
}),
|
},
|
||||||
convo => groupAvatarJobQueue.add({ conversationId: convo.id }),
|
|
||||||
{ concurrency: MAX_CONCURRENCY }
|
{ concurrency: MAX_CONCURRENCY }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -376,6 +387,16 @@ export class BackupImportStream extends Writable {
|
||||||
.map(([, id]) => id)
|
.map(([, id]) => id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await loadAll();
|
||||||
|
reinitializeRedux(getParametersForRedux());
|
||||||
|
|
||||||
|
await window.storage.put(
|
||||||
|
'backupAttachmentsTotalSizeToDownload',
|
||||||
|
await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs()
|
||||||
|
);
|
||||||
|
|
||||||
|
await AttachmentDownloadManager.start();
|
||||||
|
|
||||||
done();
|
done();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
done(error);
|
done(error);
|
||||||
|
@ -843,12 +864,6 @@ export class BackupImportStream extends Writable {
|
||||||
// Snapshot
|
// Snapshot
|
||||||
name: dropNull(title?.title),
|
name: dropNull(title?.title),
|
||||||
description: dropNull(description?.descriptionText),
|
description: dropNull(description?.descriptionText),
|
||||||
avatar: avatarUrl
|
|
||||||
? {
|
|
||||||
url: avatarUrl,
|
|
||||||
path: '',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
expireTimer: expirationTimerS
|
expireTimer: expirationTimerS
|
||||||
? DurationInSeconds.fromSeconds(expirationTimerS)
|
? DurationInSeconds.fromSeconds(expirationTimerS)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -937,6 +952,9 @@ export class BackupImportStream extends Writable {
|
||||||
: undefined,
|
: undefined,
|
||||||
announcementsOnly: dropNull(announcementsOnly),
|
announcementsOnly: dropNull(announcementsOnly),
|
||||||
};
|
};
|
||||||
|
if (avatarUrl) {
|
||||||
|
this.pendingGroupAvatars.set(attrs.id, avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,6 @@ import * as Errors from '../../types/errors';
|
||||||
import { HTTPError } from '../../textsecure/Errors';
|
import { HTTPError } from '../../textsecure/Errors';
|
||||||
import { constantTimeEqual } from '../../Crypto';
|
import { constantTimeEqual } from '../../Crypto';
|
||||||
import { measureSize } from '../../AttachmentCrypto';
|
import { measureSize } from '../../AttachmentCrypto';
|
||||||
import { reinitializeRedux } from '../../state/reinitializeRedux';
|
|
||||||
import { isTestOrMockEnvironment } from '../../environment';
|
import { isTestOrMockEnvironment } from '../../environment';
|
||||||
import { BackupExportStream } from './export';
|
import { BackupExportStream } from './export';
|
||||||
import { BackupImportStream } from './import';
|
import { BackupImportStream } from './import';
|
||||||
|
@ -39,8 +38,6 @@ import { getKeyMaterial } from './crypto';
|
||||||
import { BackupCredentials } from './credentials';
|
import { BackupCredentials } from './credentials';
|
||||||
import { BackupAPI, type DownloadOptionsType } from './api';
|
import { BackupAPI, type DownloadOptionsType } from './api';
|
||||||
import { validateBackup } from './validator';
|
import { validateBackup } from './validator';
|
||||||
import { getParametersForRedux, loadAll } from '../allLoaders';
|
|
||||||
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
|
|
||||||
|
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
|
@ -151,7 +148,7 @@ export class BackupsService {
|
||||||
public async download(
|
public async download(
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
|
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
let downloadOffset = 0;
|
let downloadOffset = 0;
|
||||||
try {
|
try {
|
||||||
({ size: downloadOffset } = await stat(downloadPath));
|
({ size: downloadOffset } = await stat(downloadPath));
|
||||||
|
@ -187,7 +184,7 @@ export class BackupsService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No backup on the server
|
// No backup on the server
|
||||||
if (error instanceof HTTPError && error.code === 404) {
|
if (error instanceof HTTPError && error.code === 404) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -197,6 +194,8 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importBackup(
|
public async importBackup(
|
||||||
|
@ -209,6 +208,7 @@ export class BackupsService {
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const importStream = await BackupImportStream.create();
|
||||||
if (backupType === BackupType.Ciphertext) {
|
if (backupType === BackupType.Ciphertext) {
|
||||||
const { aesKey, macKey } = getKeyMaterial();
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
|
|
||||||
|
@ -237,15 +237,13 @@ export class BackupsService {
|
||||||
// Second pass - decrypt (but still check the mac at the end)
|
// Second pass - decrypt (but still check the mac at the end)
|
||||||
hmac = createHmac(HashType.size256, macKey);
|
hmac = createHmac(HashType.size256, macKey);
|
||||||
|
|
||||||
await this.prepareForImport();
|
|
||||||
|
|
||||||
await pipeline(
|
await pipeline(
|
||||||
createBackupStream(),
|
createBackupStream(),
|
||||||
getMacAndUpdateHmac(hmac, noop),
|
getMacAndUpdateHmac(hmac, noop),
|
||||||
getIvAndDecipher(aesKey),
|
getIvAndDecipher(aesKey),
|
||||||
createGunzip(),
|
createGunzip(),
|
||||||
new DelimitedStream(),
|
new DelimitedStream(),
|
||||||
new BackupImportStream()
|
importStream
|
||||||
);
|
);
|
||||||
|
|
||||||
strictAssert(
|
strictAssert(
|
||||||
|
@ -260,14 +258,12 @@ export class BackupsService {
|
||||||
await pipeline(
|
await pipeline(
|
||||||
createBackupStream(),
|
createBackupStream(),
|
||||||
new DelimitedStream(),
|
new DelimitedStream(),
|
||||||
new BackupImportStream()
|
importStream
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw missingCaseError(backupType);
|
throw missingCaseError(backupType);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.resetStateAfterImport();
|
|
||||||
|
|
||||||
log.info('importBackup: finished...');
|
log.info('importBackup: finished...');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
||||||
|
@ -281,27 +277,6 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async prepareForImport(): Promise<void> {
|
|
||||||
await AttachmentDownloadManager.stop();
|
|
||||||
await DataWriter.removeAllBackupAttachmentDownloadJobs();
|
|
||||||
await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
|
|
||||||
await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async resetStateAfterImport(): Promise<void> {
|
|
||||||
window.ConversationController.reset();
|
|
||||||
await window.ConversationController.load();
|
|
||||||
await loadAll();
|
|
||||||
reinitializeRedux(getParametersForRedux());
|
|
||||||
|
|
||||||
await window.storage.put(
|
|
||||||
'backupAttachmentsTotalSizeToDownload',
|
|
||||||
await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs()
|
|
||||||
);
|
|
||||||
|
|
||||||
await AttachmentDownloadManager.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
|
public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
|
||||||
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
|
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
|
||||||
await DataWriter.clearAllBackupCdnObjectMetadata();
|
await DataWriter.clearAllBackupCdnObjectMetadata();
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { actions as emojis } from './ducks/emojis';
|
||||||
import { actions as expiration } from './ducks/expiration';
|
import { actions as expiration } from './ducks/expiration';
|
||||||
import { actions as globalModals } from './ducks/globalModals';
|
import { actions as globalModals } from './ducks/globalModals';
|
||||||
import { actions as inbox } from './ducks/inbox';
|
import { actions as inbox } from './ducks/inbox';
|
||||||
|
import { actions as installer } from './ducks/installer';
|
||||||
import { actions as items } from './ducks/items';
|
import { actions as items } from './ducks/items';
|
||||||
import { actions as lightbox } from './ducks/lightbox';
|
import { actions as lightbox } from './ducks/lightbox';
|
||||||
import { actions as linkPreviews } from './ducks/linkPreviews';
|
import { actions as linkPreviews } from './ducks/linkPreviews';
|
||||||
|
@ -46,6 +47,7 @@ export const actionCreators: ReduxActions = {
|
||||||
expiration,
|
expiration,
|
||||||
globalModals,
|
globalModals,
|
||||||
inbox,
|
inbox,
|
||||||
|
installer,
|
||||||
items,
|
items,
|
||||||
lightbox,
|
lightbox,
|
||||||
linkPreviews,
|
linkPreviews,
|
||||||
|
|
|
@ -7,6 +7,12 @@ import type { StateType as RootStateType } from '../reducer';
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
|
import {
|
||||||
|
START_INSTALLER,
|
||||||
|
type StartInstallerActionType,
|
||||||
|
SHOW_BACKUP_IMPORT,
|
||||||
|
type ShowBackupImportActionType,
|
||||||
|
} from './installer';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -15,41 +21,19 @@ export enum AppViewType {
|
||||||
Inbox = 'Inbox',
|
Inbox = 'Inbox',
|
||||||
Installer = 'Installer',
|
Installer = 'Installer',
|
||||||
Standalone = 'Standalone',
|
Standalone = 'Standalone',
|
||||||
BackupImport = 'BackupImport',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppStateType = ReadonlyDeep<
|
export type AppStateType = ReadonlyDeep<{
|
||||||
{
|
hasInitialLoadCompleted: boolean;
|
||||||
hasInitialLoadCompleted: boolean;
|
appView: AppViewType;
|
||||||
} & (
|
}>;
|
||||||
| {
|
|
||||||
appView: AppViewType.Blank;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
appView: AppViewType.Inbox;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
appView: AppViewType.Installer;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
appView: AppViewType.Standalone;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
appView: AppViewType.BackupImport;
|
|
||||||
currentBytes?: number;
|
|
||||||
totalBytes?: number;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE';
|
const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE';
|
||||||
const OPEN_INBOX = 'app/OPEN_INBOX';
|
const OPEN_INBOX = 'app/OPEN_INBOX';
|
||||||
const OPEN_INSTALLER = 'app/OPEN_INSTALLER';
|
export const OPEN_INSTALLER = 'app/OPEN_INSTALLER';
|
||||||
const OPEN_STANDALONE = 'app/OPEN_STANDALONE';
|
const OPEN_STANDALONE = 'app/OPEN_STANDALONE';
|
||||||
const OPEN_BACKUP_IMPORT = 'app/OPEN_BACKUP_IMPORT';
|
|
||||||
const UPDATE_BACKUP_IMPORT_PROGRESS = 'app/UPDATE_BACKUP_IMPORT_PROGRESS';
|
|
||||||
|
|
||||||
type InitialLoadCompleteActionType = ReadonlyDeep<{
|
type InitialLoadCompleteActionType = ReadonlyDeep<{
|
||||||
type: typeof INITIAL_LOAD_COMPLETE;
|
type: typeof INITIAL_LOAD_COMPLETE;
|
||||||
|
@ -59,42 +43,18 @@ type OpenInboxActionType = ReadonlyDeep<{
|
||||||
type: typeof OPEN_INBOX;
|
type: typeof OPEN_INBOX;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type OpenInstallerActionType = ReadonlyDeep<{
|
|
||||||
type: typeof OPEN_INSTALLER;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type OpenStandaloneActionType = ReadonlyDeep<{
|
type OpenStandaloneActionType = ReadonlyDeep<{
|
||||||
type: typeof OPEN_STANDALONE;
|
type: typeof OPEN_STANDALONE;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type OpenBackupImportActionType = ReadonlyDeep<{
|
|
||||||
type: typeof OPEN_BACKUP_IMPORT;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type UpdateBackupImportProgressActionType = ReadonlyDeep<{
|
|
||||||
type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
|
|
||||||
payload: {
|
|
||||||
currentBytes: number;
|
|
||||||
totalBytes: number;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type AppActionType = ReadonlyDeep<
|
export type AppActionType = ReadonlyDeep<
|
||||||
| InitialLoadCompleteActionType
|
InitialLoadCompleteActionType | OpenInboxActionType | OpenStandaloneActionType
|
||||||
| OpenInboxActionType
|
|
||||||
| OpenInstallerActionType
|
|
||||||
| OpenStandaloneActionType
|
|
||||||
| OpenBackupImportActionType
|
|
||||||
| UpdateBackupImportProgressActionType
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
initialLoadComplete,
|
initialLoadComplete,
|
||||||
openInbox,
|
openInbox,
|
||||||
openInstaller,
|
|
||||||
openStandalone,
|
openStandalone,
|
||||||
openBackupImport,
|
|
||||||
updateBackupImportProgress,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppActions = (): BoundActionCreatorsMapObject<typeof actions> =>
|
export const useAppActions = (): BoundActionCreatorsMapObject<typeof actions> =>
|
||||||
|
@ -123,21 +83,6 @@ function openInbox(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInstaller(): ThunkAction<
|
|
||||||
void,
|
|
||||||
RootStateType,
|
|
||||||
unknown,
|
|
||||||
OpenInstallerActionType
|
|
||||||
> {
|
|
||||||
return dispatch => {
|
|
||||||
window.IPC.addSetupMenuItems();
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: OPEN_INSTALLER,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function openStandalone(): ThunkAction<
|
function openStandalone(): ThunkAction<
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
|
@ -156,16 +101,6 @@ function openStandalone(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBackupImport(): OpenBackupImportActionType {
|
|
||||||
return { type: OPEN_BACKUP_IMPORT };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBackupImportProgress(
|
|
||||||
payload: UpdateBackupImportProgressActionType['payload']
|
|
||||||
): UpdateBackupImportProgressActionType {
|
|
||||||
return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reducer
|
// Reducer
|
||||||
|
|
||||||
export function getEmptyState(): AppStateType {
|
export function getEmptyState(): AppStateType {
|
||||||
|
@ -177,7 +112,9 @@ export function getEmptyState(): AppStateType {
|
||||||
|
|
||||||
export function reducer(
|
export function reducer(
|
||||||
state: Readonly<AppStateType> = getEmptyState(),
|
state: Readonly<AppStateType> = getEmptyState(),
|
||||||
action: Readonly<AppActionType>
|
action: Readonly<
|
||||||
|
AppActionType | StartInstallerActionType | ShowBackupImportActionType
|
||||||
|
>
|
||||||
): AppStateType {
|
): AppStateType {
|
||||||
if (action.type === OPEN_INBOX) {
|
if (action.type === OPEN_INBOX) {
|
||||||
return {
|
return {
|
||||||
|
@ -193,13 +130,6 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === OPEN_INSTALLER) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
appView: AppViewType.Installer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === OPEN_STANDALONE) {
|
if (action.type === OPEN_STANDALONE) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
@ -207,22 +137,11 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === OPEN_BACKUP_IMPORT) {
|
// Foreign action
|
||||||
|
if (action.type === START_INSTALLER || action.type === SHOW_BACKUP_IMPORT) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
appView: AppViewType.BackupImport,
|
appView: AppViewType.Installer,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) {
|
|
||||||
if (state.appView !== AppViewType.BackupImport) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
currentBytes: action.payload.currentBytes,
|
|
||||||
totalBytes: action.payload.totalBytes,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
558
ts/state/ducks/installer.ts
Normal file
558
ts/state/ducks/installer.ts
Normal file
|
@ -0,0 +1,558 @@
|
||||||
|
// 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 pTimeout, { TimeoutError } from 'p-timeout';
|
||||||
|
|
||||||
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
|
import {
|
||||||
|
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 { SECOND } from '../../util/durations';
|
||||||
|
import * as Registration from '../../util/registration';
|
||||||
|
import { isBackupEnabled } from '../../util/isBackupEnabled';
|
||||||
|
import { HTTPError } from '../../textsecure/Errors';
|
||||||
|
import {
|
||||||
|
Provisioner,
|
||||||
|
type PrepareLinkDataOptionsType,
|
||||||
|
} from '../../textsecure/Provisioner';
|
||||||
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
import * as log from '../../logging/log';
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
export type InstallerStateType = ReadonlyDeep<
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.NotStarted;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.QrCodeNotScanned;
|
||||||
|
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
|
||||||
|
baton: BatonType;
|
||||||
|
attemptCount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.ChoosingDeviceName;
|
||||||
|
deviceName: string;
|
||||||
|
backupFile?: File;
|
||||||
|
baton: BatonType;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.Error;
|
||||||
|
error: InstallScreenError;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.LinkInProgress;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
step: InstallScreenStep.BackupImport;
|
||||||
|
currentBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
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: {
|
||||||
|
currentBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type InstallerActionType = ReadonlyDeep<
|
||||||
|
| StartInstallerActionType
|
||||||
|
| SetProvisioningUrlActionType
|
||||||
|
| SetQRCodeErrorActionType
|
||||||
|
| SetErrorActionType
|
||||||
|
| QRCodeScannedActionType
|
||||||
|
| ShowLinkInProgressActionType
|
||||||
|
| ShowBackupImportActionType
|
||||||
|
| UpdateBackupImportProgressActionType
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
startInstaller,
|
||||||
|
finishInstall,
|
||||||
|
updateBackupImportProgress,
|
||||||
|
showBackupImport,
|
||||||
|
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 { attemptCount } = state;
|
||||||
|
|
||||||
|
// Can't retry past attempt count
|
||||||
|
if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) {
|
||||||
|
log.error('InstallScreen/getQRCode: too many tries');
|
||||||
|
dispatch({
|
||||||
|
type: SET_ERROR,
|
||||||
|
payload: InstallScreenError.QRCodeFailed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server } = window.textsecure;
|
||||||
|
strictAssert(server, 'Expected a server');
|
||||||
|
|
||||||
|
const provisioner = new Provisioner(server);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.IPC.removeSetupMenuItems();
|
||||||
|
dispatch({
|
||||||
|
type: SET_PROVISIONING_URL,
|
||||||
|
payload: url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
provisioner.close();
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
'installer: got an error while waiting for QR code',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Too many attempts, there is probably some issue
|
||||||
|
if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) {
|
||||||
|
log.error('InstallScreen/getQRCode: too many tries');
|
||||||
|
dispatch({
|
||||||
|
type: SET_ERROR,
|
||||||
|
payload: InstallScreenError.QRCodeFailed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed out, let user retry
|
||||||
|
if (error === SLEEP_ERROR) {
|
||||||
|
dispatch({
|
||||||
|
type: SET_QR_CODE_ERROR,
|
||||||
|
payload: InstallScreenQRCodeError.Timeout,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HTTPError && error.code === -1) {
|
||||||
|
if (
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: SET_ERROR,
|
||||||
|
payload: InstallScreenError.ConnectionFailed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provisionerByBaton.set(baton, provisioner);
|
||||||
|
|
||||||
|
// Switch to next UI phase
|
||||||
|
dispatch({
|
||||||
|
type: QR_CODE_SCANNED,
|
||||||
|
payload: {
|
||||||
|
deviceName:
|
||||||
|
window.textsecure.storage.user.getDeviceName() ||
|
||||||
|
window.getHostName() ||
|
||||||
|
'',
|
||||||
|
baton,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// And feed it the CI data if present
|
||||||
|
const { SignalCI } = window;
|
||||||
|
if (SignalCI != null) {
|
||||||
|
dispatch(
|
||||||
|
finishInstall({
|
||||||
|
deviceName: SignalCI.deviceName,
|
||||||
|
backupFile: SignalCI.backupData,
|
||||||
|
isPlaintextBackup: SignalCI.isPlaintextBackup,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishInstall(
|
||||||
|
options: PrepareLinkDataOptionsType
|
||||||
|
): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
| SetQRCodeErrorActionType
|
||||||
|
| SetErrorActionType
|
||||||
|
| ShowLinkInProgressActionType
|
||||||
|
| ShowBackupImportActionType
|
||||||
|
> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
strictAssert(
|
||||||
|
state.installer.step === InstallScreenStep.ChoosingDeviceName,
|
||||||
|
'Not choosing device name'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { baton } = state.installer;
|
||||||
|
const provisioner = provisionerByBaton.get(baton);
|
||||||
|
strictAssert(
|
||||||
|
provisioner != null,
|
||||||
|
'Provisioner is not waiting for device info'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
controllerByBaton.delete(baton);
|
||||||
|
provisionerByBaton.delete(baton);
|
||||||
|
|
||||||
|
const accountManager = window.getAccountManager();
|
||||||
|
strictAssert(accountManager, 'Expected an account manager');
|
||||||
|
|
||||||
|
if (isBackupEnabled()) {
|
||||||
|
dispatch({ type: SHOW_BACKUP_IMPORT });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: SHOW_LINK_IN_PROGRESS });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = provisioner.prepareLinkData(options);
|
||||||
|
await accountManager.registerSecondDevice(data);
|
||||||
|
} 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 updateBackupImportProgress(
|
||||||
|
payload: UpdateBackupImportProgressActionType['payload']
|
||||||
|
): UpdateBackupImportProgressActionType {
|
||||||
|
return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 controller = controllerByBaton.get(state.baton);
|
||||||
|
controller?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
step: InstallScreenStep.QrCodeNotScanned,
|
||||||
|
provisioningUrl: {
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
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.ChoosingDeviceName &&
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentBytes: action.payload.currentBytes,
|
||||||
|
totalBytes: action.payload.totalBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import { getEmptyState as emojiEmptyState } from './ducks/emojis';
|
||||||
import { getEmptyState as expirationEmptyState } from './ducks/expiration';
|
import { getEmptyState as expirationEmptyState } from './ducks/expiration';
|
||||||
import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals';
|
import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals';
|
||||||
import { getEmptyState as inboxEmptyState } from './ducks/inbox';
|
import { getEmptyState as inboxEmptyState } from './ducks/inbox';
|
||||||
|
import { getEmptyState as installerEmptyState } from './ducks/installer';
|
||||||
import { getEmptyState as itemsEmptyState } from './ducks/items';
|
import { getEmptyState as itemsEmptyState } from './ducks/items';
|
||||||
import { getEmptyState as lightboxEmptyState } from './ducks/lightbox';
|
import { getEmptyState as lightboxEmptyState } from './ducks/lightbox';
|
||||||
import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews';
|
import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews';
|
||||||
|
@ -133,6 +134,7 @@ function getEmptyState(): StateType {
|
||||||
expiration: expirationEmptyState(),
|
expiration: expirationEmptyState(),
|
||||||
globalModals: globalModalsEmptyState(),
|
globalModals: globalModalsEmptyState(),
|
||||||
inbox: inboxEmptyState(),
|
inbox: inboxEmptyState(),
|
||||||
|
installer: installerEmptyState(),
|
||||||
items: itemsEmptyState(),
|
items: itemsEmptyState(),
|
||||||
lightbox: lightboxEmptyState(),
|
lightbox: lightboxEmptyState(),
|
||||||
linkPreviews: linkPreviewsEmptyState(),
|
linkPreviews: linkPreviewsEmptyState(),
|
||||||
|
|
|
@ -42,6 +42,7 @@ export function initializeRedux(data: ReduxInitData): void {
|
||||||
window.reduxActions = {
|
window.reduxActions = {
|
||||||
accounts: bindActionCreators(actionCreators.accounts, store.dispatch),
|
accounts: bindActionCreators(actionCreators.accounts, store.dispatch),
|
||||||
app: bindActionCreators(actionCreators.app, store.dispatch),
|
app: bindActionCreators(actionCreators.app, store.dispatch),
|
||||||
|
installer: bindActionCreators(actionCreators.installer, store.dispatch),
|
||||||
audioPlayer: bindActionCreators(actionCreators.audioPlayer, store.dispatch),
|
audioPlayer: bindActionCreators(actionCreators.audioPlayer, store.dispatch),
|
||||||
audioRecorder: bindActionCreators(
|
audioRecorder: bindActionCreators(
|
||||||
actionCreators.audioRecorder,
|
actionCreators.audioRecorder,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { reducer as emojis } from './ducks/emojis';
|
||||||
import { reducer as expiration } from './ducks/expiration';
|
import { reducer as expiration } from './ducks/expiration';
|
||||||
import { reducer as globalModals } from './ducks/globalModals';
|
import { reducer as globalModals } from './ducks/globalModals';
|
||||||
import { reducer as inbox } from './ducks/inbox';
|
import { reducer as inbox } from './ducks/inbox';
|
||||||
|
import { reducer as installer } from './ducks/installer';
|
||||||
import { reducer as items } from './ducks/items';
|
import { reducer as items } from './ducks/items';
|
||||||
import { reducer as lightbox } from './ducks/lightbox';
|
import { reducer as lightbox } from './ducks/lightbox';
|
||||||
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
||||||
|
@ -49,6 +50,7 @@ export const reducer = combineReducers({
|
||||||
expiration,
|
expiration,
|
||||||
globalModals,
|
globalModals,
|
||||||
inbox,
|
inbox,
|
||||||
|
installer,
|
||||||
items,
|
items,
|
||||||
lightbox,
|
lightbox,
|
||||||
linkPreviews,
|
linkPreviews,
|
||||||
|
|
8
ts/state/selectors/installer.ts
Normal file
8
ts/state/selectors/installer.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { StateType } from '../reducer';
|
||||||
|
import type { InstallerStateType } from '../ducks/installer';
|
||||||
|
|
||||||
|
export const getInstallerState = (state: StateType): InstallerStateType =>
|
||||||
|
state.installer;
|
|
@ -18,7 +18,6 @@ import {
|
||||||
getIsMainWindowMaximized,
|
getIsMainWindowMaximized,
|
||||||
getIsMainWindowFullScreen,
|
getIsMainWindowFullScreen,
|
||||||
getTheme,
|
getTheme,
|
||||||
getIntl,
|
|
||||||
} from '../selectors/user';
|
} from '../selectors/user';
|
||||||
import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories';
|
import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories';
|
||||||
import { useAppActions } from '../ducks/app';
|
import { useAppActions } from '../ducks/app';
|
||||||
|
@ -111,7 +110,6 @@ async function uploadProfile({
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SmartApp = memo(function SmartApp() {
|
export const SmartApp = memo(function SmartApp() {
|
||||||
const i18n = useSelector(getIntl);
|
|
||||||
const state = useSelector(getApp);
|
const state = useSelector(getApp);
|
||||||
const isMaximized = useSelector(getIsMainWindowMaximized);
|
const isMaximized = useSelector(getIsMainWindowMaximized);
|
||||||
const isFullScreen = useSelector(getIsMainWindowFullScreen);
|
const isFullScreen = useSelector(getIsMainWindowFullScreen);
|
||||||
|
@ -126,7 +124,6 @@ export const SmartApp = memo(function SmartApp() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<App
|
<App
|
||||||
i18n={i18n}
|
|
||||||
state={state}
|
state={state}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
isFullScreen={isFullScreen}
|
isFullScreen={isFullScreen}
|
||||||
|
|
|
@ -2,364 +2,73 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { memo, useCallback, useState, useEffect } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import pTimeout, { TimeoutError } from 'p-timeout';
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { getUpdatesState } from '../selectors/updates';
|
import { getUpdatesState } from '../selectors/updates';
|
||||||
|
import { getInstallerState } from '../selectors/installer';
|
||||||
|
import { useInstallerActions } from '../ducks/installer';
|
||||||
import { useUpdatesActions } from '../ducks/updates';
|
import { useUpdatesActions } from '../ducks/updates';
|
||||||
import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
|
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, strictAssert } from '../../util/assert';
|
|
||||||
import { explodePromise } from '../../util/explodePromise';
|
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import * as Registration from '../../util/registration';
|
import { InstallScreen } from '../../components/InstallScreen';
|
||||||
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 { WidthBreakpoint } from '../../components/_util';
|
||||||
import { HTTPError } from '../../textsecure/Errors';
|
import { InstallScreenStep } from '../../types/InstallScreen';
|
||||||
import { isRecord } from '../../util/isRecord';
|
|
||||||
import { Provisioner } from '../../textsecure/Provisioner';
|
|
||||||
import * as Errors from '../../types/errors';
|
|
||||||
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
|
|
||||||
import OS from '../../util/os/osMain';
|
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';
|
import { fileToBytes } from '../../util/fileToBytes';
|
||||||
|
import * as log from '../../logging/log';
|
||||||
|
import { SmartToastManager } from './ToastManager';
|
||||||
|
|
||||||
type PropsType = ComponentProps<typeof InstallScreen>;
|
type PropsType = ComponentProps<typeof InstallScreen>;
|
||||||
|
|
||||||
type StateType =
|
|
||||||
| {
|
|
||||||
step: InstallScreenStep.Error;
|
|
||||||
error: InstallError;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
step: InstallScreenStep.QrCodeNotScanned;
|
|
||||||
provisioningUrl: Loadable<string, LoadError>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
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() {
|
export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
|
const installerState = useSelector(getInstallerState);
|
||||||
const updates = useSelector(getUpdatesState);
|
const updates = useSelector(getUpdatesState);
|
||||||
|
const { startInstaller, finishInstall } = useInstallerActions();
|
||||||
const { startUpdate } = useUpdatesActions();
|
const { startUpdate } = useUpdatesActions();
|
||||||
const hasExpired = useSelector(hasExpiredSelector);
|
const hasExpired = useSelector(hasExpiredSelector);
|
||||||
|
|
||||||
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());
|
const [deviceName, setDeviceName] = useState<string>('');
|
||||||
const chooseBackupFilePromiseWrapperRef =
|
const [backupFile, setBackupFile] = useState<File | undefined>();
|
||||||
useRef(explodePromise<File | undefined>());
|
|
||||||
|
|
||||||
const [state, setState] = useState<StateType>(INITIAL_STATE);
|
const onSubmitDeviceName = useCallback(async () => {
|
||||||
const [retryCounter, setRetryCounter] = useState(0);
|
if (backupFile != null) {
|
||||||
|
// This is only for testing so don't bother catching errors
|
||||||
const setProvisioningUrl = useCallback(
|
finishInstall({ deviceName, backupFile: await fileToBytes(backupFile) });
|
||||||
(value: string) => {
|
} else {
|
||||||
setState(currentState => {
|
finishInstall({ deviceName, backupFile: undefined });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}, [backupFile, deviceName, finishInstall]);
|
||||||
|
|
||||||
let deviceName: string = normalizeDeviceName(state.deviceName);
|
const suggestedDeviceName =
|
||||||
if (!deviceName.length) {
|
installerState.step === InstallScreenStep.ChoosingDeviceName
|
||||||
// This should be impossible, but we have it here just in case.
|
? installerState.deviceName
|
||||||
assertDev(
|
: undefined;
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
let hasCleanedUp = false;
|
setDeviceName(suggestedDeviceName ?? '');
|
||||||
|
}, [suggestedDeviceName]);
|
||||||
const { server } = window.textsecure;
|
|
||||||
strictAssert(server, 'Expected a server');
|
|
||||||
|
|
||||||
let provisioner = new Provisioner(server);
|
|
||||||
const accountManager = window.getAccountManager();
|
|
||||||
strictAssert(accountManager, 'Expected an account manager');
|
|
||||||
|
|
||||||
async function getQRCode(): Promise<void> {
|
|
||||||
const sleepError = new TimeoutError();
|
|
||||||
try {
|
|
||||||
const qrCodePromise = provisioner.getURL();
|
|
||||||
const sleepMs = qrCodeBackOff.getAndIncrement();
|
|
||||||
log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`);
|
|
||||||
|
|
||||||
const url = await pTimeout(qrCodePromise, sleepMs, sleepError);
|
|
||||||
if (hasCleanedUp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.IPC.removeSetupMenuItems();
|
|
||||||
setProvisioningUrl(url);
|
|
||||||
|
|
||||||
await provisioner.waitForEnvelope();
|
|
||||||
onQrCodeScanned();
|
|
||||||
|
|
||||||
let deviceName: string;
|
|
||||||
let backupFileData: Uint8Array | undefined;
|
|
||||||
let isPlaintextBackup = false;
|
|
||||||
if (window.SignalCI) {
|
|
||||||
({
|
|
||||||
deviceName,
|
|
||||||
backupData: backupFileData,
|
|
||||||
isPlaintextBackup = false,
|
|
||||||
} = 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = provisioner.prepareLinkData({
|
|
||||||
deviceName,
|
|
||||||
backupFile: backupFileData,
|
|
||||||
isPlaintextBackup,
|
|
||||||
});
|
|
||||||
await accountManager.registerSecondDevice(data);
|
|
||||||
} catch (error) {
|
|
||||||
provisioner.close();
|
|
||||||
strictAssert(server, 'Expected a server');
|
|
||||||
provisioner = new Provisioner(server);
|
|
||||||
|
|
||||||
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;
|
let props: PropsType;
|
||||||
|
|
||||||
switch (state.step) {
|
switch (installerState.step) {
|
||||||
case InstallScreenStep.Error:
|
case InstallScreenStep.NotStarted:
|
||||||
props = {
|
log.error('InstallScreen: Installer not started');
|
||||||
step: InstallScreenStep.Error,
|
return null;
|
||||||
screenSpecificProps: {
|
|
||||||
i18n,
|
|
||||||
error: state.error,
|
|
||||||
quit: () => window.IPC.shutdown(),
|
|
||||||
tryAgain: () => {
|
|
||||||
setRetryCounter(count => count + 1);
|
|
||||||
setState(INITIAL_STATE);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case InstallScreenStep.QrCodeNotScanned:
|
case InstallScreenStep.QrCodeNotScanned:
|
||||||
props = {
|
props = {
|
||||||
step: InstallScreenStep.QrCodeNotScanned,
|
step: InstallScreenStep.QrCodeNotScanned,
|
||||||
screenSpecificProps: {
|
screenSpecificProps: {
|
||||||
i18n,
|
i18n,
|
||||||
provisioningUrl: state.provisioningUrl,
|
provisioningUrl: installerState.provisioningUrl,
|
||||||
hasExpired,
|
hasExpired,
|
||||||
updates,
|
updates,
|
||||||
currentVersion: window.getVersion(),
|
currentVersion: window.getVersion(),
|
||||||
startUpdate,
|
startUpdate,
|
||||||
retryGetQrCode: () => {
|
retryGetQrCode: startInstaller,
|
||||||
setRetryCounter(count => count + 1);
|
|
||||||
setState(INITIAL_STATE);
|
|
||||||
},
|
|
||||||
OS: OS.getName(),
|
OS: OS.getName(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -369,7 +78,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
step: InstallScreenStep.ChoosingDeviceName,
|
step: InstallScreenStep.ChoosingDeviceName,
|
||||||
screenSpecificProps: {
|
screenSpecificProps: {
|
||||||
i18n,
|
i18n,
|
||||||
deviceName: state.deviceName,
|
deviceName,
|
||||||
setDeviceName,
|
setDeviceName,
|
||||||
setBackupFile,
|
setBackupFile,
|
||||||
onSubmit: onSubmitDeviceName,
|
onSubmit: onSubmitDeviceName,
|
||||||
|
@ -382,8 +91,29 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||||
screenSpecificProps: { i18n },
|
screenSpecificProps: { i18n },
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case InstallScreenStep.BackupImport:
|
||||||
|
props = {
|
||||||
|
step: InstallScreenStep.BackupImport,
|
||||||
|
screenSpecificProps: {
|
||||||
|
i18n,
|
||||||
|
currentBytes: installerState.currentBytes,
|
||||||
|
totalBytes: installerState.totalBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case InstallScreenStep.Error:
|
||||||
|
props = {
|
||||||
|
step: InstallScreenStep.Error,
|
||||||
|
screenSpecificProps: {
|
||||||
|
i18n,
|
||||||
|
error: installerState.error,
|
||||||
|
quit: () => window.IPC.shutdown(),
|
||||||
|
tryAgain: startInstaller,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(state);
|
throw missingCaseError(installerState);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type { actions as emojis } from './ducks/emojis';
|
||||||
import type { actions as expiration } from './ducks/expiration';
|
import type { actions as expiration } from './ducks/expiration';
|
||||||
import type { actions as globalModals } from './ducks/globalModals';
|
import type { actions as globalModals } from './ducks/globalModals';
|
||||||
import type { actions as inbox } from './ducks/inbox';
|
import type { actions as inbox } from './ducks/inbox';
|
||||||
|
import type { actions as installer } from './ducks/installer';
|
||||||
import type { actions as items } from './ducks/items';
|
import type { actions as items } from './ducks/items';
|
||||||
import type { actions as lightbox } from './ducks/lightbox';
|
import type { actions as lightbox } from './ducks/lightbox';
|
||||||
import type { actions as linkPreviews } from './ducks/linkPreviews';
|
import type { actions as linkPreviews } from './ducks/linkPreviews';
|
||||||
|
@ -45,6 +46,7 @@ export type ReduxActions = {
|
||||||
expiration: typeof expiration;
|
expiration: typeof expiration;
|
||||||
globalModals: typeof globalModals;
|
globalModals: typeof globalModals;
|
||||||
inbox: typeof inbox;
|
inbox: typeof inbox;
|
||||||
|
installer: typeof installer;
|
||||||
items: typeof items;
|
items: typeof items;
|
||||||
lightbox: typeof lightbox;
|
lightbox: typeof lightbox;
|
||||||
linkPreviews: typeof linkPreviews;
|
linkPreviews: typeof linkPreviews;
|
||||||
|
|
|
@ -8,12 +8,14 @@ import {
|
||||||
import { linkDeviceRoute } from '../util/signalRoutes';
|
import { linkDeviceRoute } from '../util/signalRoutes';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { normalizeAci } from '../util/normalizeAci';
|
import { normalizeAci } from '../util/normalizeAci';
|
||||||
|
import { normalizeDeviceName } from '../util/normalizeDeviceName';
|
||||||
|
import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
import {
|
import {
|
||||||
isUntaggedPniString,
|
isUntaggedPniString,
|
||||||
normalizePni,
|
normalizePni,
|
||||||
toTaggedPni,
|
toTaggedPni,
|
||||||
} from '../types/ServiceId';
|
} from '../types/ServiceId';
|
||||||
import * as Errors from '../types/errors';
|
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
@ -204,7 +206,10 @@ export class Provisioner {
|
||||||
aciKeyPair,
|
aciKeyPair,
|
||||||
pniKeyPair,
|
pniKeyPair,
|
||||||
profileKey,
|
profileKey,
|
||||||
deviceName,
|
deviceName: normalizeDeviceName(deviceName).slice(
|
||||||
|
0,
|
||||||
|
MAX_DEVICE_NAME_LENGTH
|
||||||
|
),
|
||||||
backupFile,
|
backupFile,
|
||||||
isPlaintextBackup,
|
isPlaintextBackup,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|
|
@ -3669,6 +3669,7 @@ export function initialize({
|
||||||
currentBytes += chunk.byteLength;
|
currentBytes += chunk.byteLength;
|
||||||
onProgress(currentBytes, totalBytes);
|
onProgress(currentBytes, totalBytes);
|
||||||
});
|
});
|
||||||
|
onProgress(0, totalBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return combinedStream;
|
return combinedStream;
|
||||||
|
|
31
ts/types/InstallScreen.ts
Normal file
31
ts/types/InstallScreen.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export enum InstallScreenStep {
|
||||||
|
NotStarted = 'NotStarted',
|
||||||
|
QrCodeNotScanned = 'QrCodeNotScanned',
|
||||||
|
ChoosingDeviceName = 'ChoosingDeviceName',
|
||||||
|
Error = 'Error',
|
||||||
|
|
||||||
|
// Either of these two is the final state
|
||||||
|
LinkInProgress = 'LinkInProgress',
|
||||||
|
BackupImport = 'BackupImport',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InstallScreenError {
|
||||||
|
TooManyDevices = 'TooManyDevices',
|
||||||
|
TooOld = 'TooOld',
|
||||||
|
ConnectionFailed = 'ConnectionFailed',
|
||||||
|
QRCodeFailed = 'QRCodeFailed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InstallScreenQRCodeError {
|
||||||
|
Timeout = 'Timeout',
|
||||||
|
Unknown = 'Unknown',
|
||||||
|
NetworkIssue = 'NetworkIssue',
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the string's `.length`, which is the number of UTF-16 code points. Instead, we
|
||||||
|
// want this to be either 50 graphemes or 256 encrypted bytes, whichever is smaller. See
|
||||||
|
// DESKTOP-2844.
|
||||||
|
export const MAX_DEVICE_NAME_LENGTH = 50;
|
|
@ -3088,21 +3088,6 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2023-08-20T22:14:52.008Z"
|
"updated": "2023-08-20T22:14:52.008Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/state/smart/InstallScreen.tsx",
|
|
||||||
"line": " const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());",
|
|
||||||
"reasonCategory": "testCode",
|
|
||||||
"updated": "2023-11-16T23:39:21.322Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/state/smart/InstallScreen.tsx",
|
|
||||||
"line": " useRef(explodePromise<File | undefined>());",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2021-12-06T23:07:28.947Z",
|
|
||||||
"reasonDetail": "Doesn't touch the DOM."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "DOM-innerHTML",
|
"rule": "DOM-innerHTML",
|
||||||
"path": "ts/windows/loading/start.ts",
|
"path": "ts/windows/loading/start.ts",
|
||||||
|
|
Loading…
Reference in a new issue