Show update UI on backup version mismatch

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-11-13 11:33:01 -06:00 committed by GitHub
parent fece7b0e42
commit 9914fefe1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 344 additions and 66 deletions

View file

@ -6263,6 +6263,18 @@
"messageformat": "Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible.",
"description": "The title of outage dialog during service outage."
},
"icu:InstallScreenUpdateDialog--update-required__title": {
"messageformat": "Update Required",
"description": "The title of update dialog on install screen when app update is required before proceeding with backup import"
},
"icu:InstallScreenUpdateDialog--update-required__body": {
"messageformat": "To complete syncing your messages, update Signal desktop now.",
"description": "The body of update dialog on install screen when app update is required before proceeding with backup import"
},
"icu:InstallScreenUpdateDialog--update-required__action-update": {
"messageformat": "Update",
"description": "The update action of update dialog on install screen when app update is required before proceeding with backup import"
},
"icu:InstallScreenUpdateDialog--unsupported-os__title": {
"messageformat": "Update Required",
"description": "The title of update dialog on install screen when user OS is unsupported"

View file

@ -1,30 +1,77 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useState, useCallback } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import { sleep } from '../../util/sleep';
import {
InstallScreenBackupStep,
InstallScreenBackupError,
} from '../../types/InstallScreen';
import { DialogType } from '../../types/Dialogs';
import enMessages from '../../../_locales/en/messages.json';
import type { PropsType } from './InstallScreenBackupImportStep';
import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep';
const i18n = setupI18n('en', enMessages);
const DEFAULT_UPDATES = {
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: false,
showEventsCount: 0,
downloadSize: 42 * 1024 * 1024,
};
export default {
title: 'Components/InstallScreenBackupImportStep',
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => (
<InstallScreenBackupImportStep
{...args}
i18n={i18n}
onCancel={action('onCancel')}
onRetry={action('onRetry')}
/>
);
const Template: StoryFn<PropsType> = (args: PropsType) => {
const [updates, setUpdates] = useState(DEFAULT_UPDATES);
const forceUpdate = useCallback(async () => {
setUpdates(state => ({
...state,
isCheckingForUpdates: true,
}));
await sleep(500);
setUpdates(state => ({
...state,
isCheckingForUpdates: false,
dialogType: DialogType.Downloading,
downloadSize: 100,
downloadedSize: 0,
version: 'v7.7.7',
}));
await sleep(500);
setUpdates(state => ({
...state,
downloadedSize: 50,
}));
await sleep(500);
setUpdates(state => ({
...state,
downloadedSize: 100,
}));
}, [setUpdates]);
return (
<InstallScreenBackupImportStep
{...args}
i18n={i18n}
updates={updates}
currentVersion="v6.0.0"
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={forceUpdate}
onCancel={action('onCancel')}
onRetry={action('onRetry')}
/>
);
};
export const NoBytes = Template.bind({});
NoBytes.args = {
@ -52,7 +99,15 @@ Error.args = {
backupStep: InstallScreenBackupStep.Download,
currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
hasError: true,
error: InstallScreenBackupError.Unknown,
};
export const UnsupportedVersion = Template.bind({});
UnsupportedVersion.args = {
backupStep: InstallScreenBackupStep.Process,
currentBytes: 1,
totalBytes: 1024 * 1024,
error: InstallScreenBackupError.UnsupportedVersion,
};
export const Processing = Template.bind({});

View file

@ -4,7 +4,12 @@
import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../../types/Util';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import type { UpdatesStateType } from '../../state/ducks/updates';
import {
InstallScreenStep,
InstallScreenBackupStep,
InstallScreenBackupError,
} from '../../types/InstallScreen';
import { formatFileSize } from '../../util/formatFileSize';
import { TitlebarDragArea } from '../TitlebarDragArea';
import { ProgressBar } from '../ProgressBar';
@ -14,6 +19,7 @@ import { roundFractionForProgressBar } from '../../util/numbers';
import { missingCaseError } from '../../util/missingCaseError';
import { SYNCING_MESSAGES_SECURITY_URL } from '../../types/support';
import { I18n } from '../I18n';
import { InstallScreenUpdateDialog } from './InstallScreenUpdateDialog';
// We can't always use destructuring assignment because of the complexity of this props
// type.
@ -23,9 +29,16 @@ export type PropsType = Readonly<{
backupStep: InstallScreenBackupStep;
currentBytes?: number;
totalBytes?: number;
hasError?: boolean;
error?: InstallScreenBackupError;
onCancel: () => void;
onRetry: () => void;
// Updater UI
updates: UpdatesStateType;
currentVersion: string;
OS: string;
startUpdate: () => void;
forceUpdate: () => void;
}>;
export function InstallScreenBackupImportStep({
@ -33,9 +46,15 @@ export function InstallScreenBackupImportStep({
backupStep,
currentBytes,
totalBytes,
hasError,
error,
onCancel,
onRetry,
updates,
currentVersion,
OS,
startUpdate,
forceUpdate,
}: PropsType): JSX.Element {
const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
const [isConfirmingSkip, setIsConfirmingSkip] = useState(false);
@ -123,6 +142,47 @@ export function InstallScreenBackupImportStep({
</a>
);
let errorElem: JSX.Element | undefined;
if (error == null) {
// no-op
} else if (error === InstallScreenBackupError.UnsupportedVersion) {
errorElem = (
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
step={InstallScreenStep.BackupImport}
startUpdate={startUpdate}
forceUpdate={forceUpdate}
currentVersion={currentVersion}
onClose={confirmSkip}
OS={OS}
/>
);
} else if (error === InstallScreenBackupError.Unknown) {
if (!isConfirmingSkip) {
errorElem = (
<ConfirmationDialog
dialogName="InstallScreenBackupImportStep.error"
title={i18n('icu:BackupImportScreen__error__title')}
cancelText={i18n('icu:BackupImportScreen__skip')}
actions={[
{
action: onRetryWrap,
style: 'affirmative',
text: i18n('icu:BackupImportScreen__error__confirm'),
},
]}
i18n={i18n}
onClose={confirmSkip}
>
{i18n('icu:BackupImportScreen__error__body')}
</ConfirmationDialog>
);
}
} else {
throw missingCaseError(error);
}
return (
<div className="InstallScreenBackupImportStep">
<TitlebarDragArea />
@ -202,24 +262,7 @@ export function InstallScreenBackupImportStep({
</ConfirmationDialog>
)}
{hasError && !isConfirmingSkip && (
<ConfirmationDialog
dialogName="InstallScreenBackupImportStep.error"
title={i18n('icu:BackupImportScreen__error__title')}
cancelText={i18n('icu:BackupImportScreen__skip')}
actions={[
{
action: onRetryWrap,
style: 'affirmative',
text: i18n('icu:BackupImportScreen__error__confirm'),
},
]}
i18n={i18n}
onClose={confirmSkip}
>
{i18n('icu:BackupImportScreen__error__body')}
</ConfirmationDialog>
)}
{errorElem}
</div>
);
}

View file

@ -24,6 +24,7 @@ const LOADED_URL = {
const DEFAULT_UPDATES = {
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: false,
showEventsCount: 0,
downloadSize: 67 * 1024 * 1024,
downloadedSize: 15 * 1024 * 1024,
@ -63,6 +64,7 @@ function Simulation({
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
currentVersion="v6.0.0"
retryGetQrCode={action('retryGetQrCode')}
/>
@ -80,6 +82,7 @@ export function QrCodeLoading(): JSX.Element {
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
currentVersion="v6.0.0"
retryGetQrCode={action('retryGetQrCode')}
/>
@ -98,6 +101,7 @@ export function QrCodeFailedToLoad(): JSX.Element {
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
currentVersion="v6.0.0"
retryGetQrCode={action('retryGetQrCode')}
/>
@ -113,6 +117,7 @@ export function QrCodeLoaded(): JSX.Element {
updates={DEFAULT_UPDATES}
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
currentVersion="v6.0.0"
retryGetQrCode={action('retryGetQrCode')}
/>
@ -177,6 +182,7 @@ export const WithUpdateKnobs: StoryFn<PropsType & { dialogType: DialogType }> =
}}
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
currentVersion={currentVersion}
retryGetQrCode={action('retryGetQrCode')}
/>

View file

@ -6,7 +6,10 @@ import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { LocalizerType } from '../../types/Util';
import { InstallScreenQRCodeError } from '../../types/InstallScreen';
import {
InstallScreenStep,
InstallScreenQRCodeError,
} from '../../types/InstallScreen';
import { missingCaseError } from '../../util/missingCaseError';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
@ -33,6 +36,7 @@ export type PropsType = Readonly<{
isStaging: boolean;
retryGetQrCode: () => void;
startUpdate: () => void;
forceUpdate: () => void;
}>;
const getQrCodeClassName = getClassNamesFor(
@ -51,6 +55,7 @@ export function InstallScreenQrCodeNotScannedStep({
provisioningUrl,
retryGetQrCode,
startUpdate,
forceUpdate,
updates,
}: Readonly<PropsType>): ReactElement {
return (
@ -63,7 +68,9 @@ export function InstallScreenQrCodeNotScannedStep({
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
step={InstallScreenStep.QrCodeNotScanned}
startUpdate={startUpdate}
forceUpdate={forceUpdate}
currentVersion={currentVersion}
OS={OS}
/>

View file

@ -5,6 +5,7 @@ import React from 'react';
import { noop } from 'lodash';
import { DialogType } from '../../types/Dialogs';
import { InstallScreenStep } from '../../types/InstallScreen';
import type { LocalizerType } from '../../types/Util';
import {
PRODUCTION_DOWNLOAD_URL,
@ -13,6 +14,8 @@ import {
} from '../../types/support';
import type { UpdatesStateType } from '../../state/ducks/updates';
import { isBeta } from '../../util/version';
import { missingCaseError } from '../../util/missingCaseError';
import { roundFractionForProgressBar } from '../../util/numbers';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Modal } from '../Modal';
import { I18n } from '../I18n';
@ -21,19 +24,26 @@ import { formatFileSize } from '../../util/formatFileSize';
export type PropsType = UpdatesStateType &
Readonly<{
i18n: LocalizerType;
step: InstallScreenStep;
forceUpdate: () => void;
startUpdate: () => void;
currentVersion: string;
OS: string;
onClose?: () => void;
}>;
export function InstallScreenUpdateDialog({
i18n,
step,
dialogType,
isCheckingForUpdates,
downloadSize,
downloadedSize,
forceUpdate,
startUpdate,
currentVersion,
OS,
onClose = noop,
}: PropsType): JSX.Element | null {
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
<a
@ -48,6 +58,40 @@ export function InstallScreenUpdateDialog({
const dialogName = `InstallScreenUpdateDialog.${dialogType}`;
if (dialogType === DialogType.None) {
if (step === InstallScreenStep.BackupImport) {
if (isCheckingForUpdates) {
return <DownloadingModal i18n={i18n} width={0} />;
}
return (
<ConfirmationDialog
i18n={i18n}
dialogName={dialogName}
noMouseClose
onClose={onClose}
noDefaultCancelButton
actions={[
{
id: 'ok',
text: i18n(
'icu:InstallScreenUpdateDialog--update-required__action-update'
),
action: forceUpdate,
style: 'affirmative',
autoClose: false,
},
]}
title={i18n('icu:InstallScreenUpdateDialog--update-required__title')}
>
{i18n('icu:InstallScreenUpdateDialog--update-required__body')}
</ConfirmationDialog>
);
}
return null;
}
if (dialogType === DialogType.UnsupportedOS) {
return (
<Modal
@ -109,6 +153,7 @@ export function InstallScreenUpdateDialog({
i18n={i18n}
dialogName={dialogName}
title={title}
noMouseClose
noDefaultCancelButton
actions={[
{
@ -119,7 +164,7 @@ export function InstallScreenUpdateDialog({
autoClose: false,
},
]}
onClose={noop}
onClose={onClose}
>
{bodyText}
</ConfirmationDialog>
@ -127,27 +172,10 @@ export function InstallScreenUpdateDialog({
}
if (dialogType === DialogType.Downloading) {
// Focus trap can't be used because there are no elements that can be
// focused within the modal.
const width = Math.ceil(
((downloadedSize || 1) / (downloadSize || 1)) * 100
);
return (
<Modal
i18n={i18n}
modalName={dialogName}
noMouseClose
useFocusTrap={false}
title={i18n('icu:DialogUpdate__downloading')}
>
<div className="InstallScreenUpdateDialog__progress--container">
<div
className="InstallScreenUpdateDialog__progress--bar"
style={{ transform: `translateX(${width - 100}%)` }}
/>
</div>
</Modal>
const fractionComplete = roundFractionForProgressBar(
(downloadedSize || 0) / (downloadSize || 1)
);
return <DownloadingModal i18n={i18n} width={fractionComplete * 100} />;
}
if (
@ -179,6 +207,7 @@ export function InstallScreenUpdateDialog({
dialogName={dialogName}
moduleClassName="InstallScreenUpdateDialog"
title={title}
noMouseClose
noDefaultCancelButton
actions={[
{
@ -188,7 +217,7 @@ export function InstallScreenUpdateDialog({
autoClose: false,
},
]}
onClose={noop}
onClose={onClose}
>
{body}
</ConfirmationDialog>
@ -230,5 +259,32 @@ export function InstallScreenUpdateDialog({
);
}
return null;
throw missingCaseError(dialogType);
}
export function DownloadingModal({
i18n,
width,
}: {
i18n: LocalizerType;
width: number;
}): JSX.Element {
// Focus trap can't be used because there are no elements that can be
// focused within the modal.
return (
<Modal
i18n={i18n}
modalName="InstallScreenUpdateDialog.Downloading"
noMouseClose
useFocusTrap={false}
title={i18n('icu:DialogUpdate__downloading')}
>
<div className="InstallScreenUpdateDialog__progress--container">
<div
className="InstallScreenUpdateDialog__progress--bar"
style={{ transform: `translateX(${width - 100}%)` }}
/>
</div>
</Modal>
);
}

View file

@ -0,0 +1,10 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type Long from 'long';
export class UnsupportedBackupVersion extends Error {
constructor(version: Long) {
super(`Unsupported backup version: ${version}`);
}
}

View file

@ -77,6 +77,7 @@ import { SeenStatus } from '../../MessageSeenStatus';
import { constantTimeEqual } from '../../Crypto';
import * as Bytes from '../../Bytes';
import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants';
import { UnsupportedBackupVersion } from './errors';
import type { AboutMe, LocalChatStyle } from './types';
import { BackupType } from './types';
import { getBackupMediaRootKey } from './crypto';
@ -253,7 +254,7 @@ export class BackupImportStream extends Writable {
log.info(`${this.logId}: got BackupInfo`);
if (info.version?.toNumber() !== BACKUP_VERSION) {
throw new Error(`Unsupported backup version: ${info.version}`);
throw new UnsupportedBackupVersion(info.version);
}
if (Bytes.isEmpty(info.mediaRootBackupKey)) {

View file

@ -31,7 +31,10 @@ import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer';
import { CipherType, HashType } from '../../types/Crypto';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import {
InstallScreenBackupStep,
InstallScreenBackupError,
} from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
import { BackupCredentialType } from '../../types/backups';
import { HTTPError } from '../../textsecure/Errors';
@ -46,6 +49,7 @@ import { BackupCredentials } from './credentials';
import { BackupAPI } from './api';
import { validateBackup } from './validator';
import { BackupType } from './types';
import { UnsupportedBackupVersion } from './errors';
export { BackupType };
@ -142,7 +146,10 @@ export class BackupsService {
);
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>();
window.reduxActions.installer.updateBackupImportProgress({
hasError: true,
error:
error instanceof UnsupportedBackupVersion
? InstallScreenBackupError.UnsupportedVersion
: InstallScreenBackupError.Unknown,
});
// eslint-disable-next-line no-await-in-loop

View file

@ -6,3 +6,7 @@ import { ipcRenderer } from 'electron';
export function startUpdate(): Promise<void> {
return ipcRenderer.invoke('start-update');
}
export function forceUpdate(): Promise<void> {
return ipcRenderer.invoke('updater/force-update');
}

View file

@ -7,6 +7,7 @@ import pTimeout, { TimeoutError } from 'p-timeout';
import type { StateType as RootStateType } from '../reducer';
import {
type InstallScreenBackupError,
InstallScreenBackupStep,
InstallScreenStep,
InstallScreenError,
@ -67,7 +68,7 @@ export type InstallerStateType = ReadonlyDeep<
backupStep: InstallScreenBackupStep;
currentBytes?: number;
totalBytes?: number;
hasError?: boolean;
error?: InstallScreenBackupError;
}
>;
@ -132,7 +133,7 @@ type UpdateBackupImportProgressActionType = ReadonlyDeep<{
totalBytes: number;
}
| {
hasError: boolean;
error: InstallScreenBackupError;
};
}>;
@ -600,10 +601,10 @@ export function reducer(
return state;
}
if ('hasError' in action.payload) {
if ('error' in action.payload) {
return {
...state,
hasError: action.payload.hasError,
error: action.payload.error,
};
}
@ -626,7 +627,7 @@ export function reducer(
return {
...state,
hasError: false,
error: undefined,
};
}

View file

@ -18,6 +18,7 @@ export type UpdatesStateType = ReadonlyDeep<{
downloadSize?: number;
downloadedSize?: number;
showEventsCount: number;
isCheckingForUpdates: boolean;
version?: string;
}>;
@ -27,6 +28,8 @@ const DISMISS_DIALOG = 'updates/DISMISS_DIALOG';
const SHOW_UPDATE_DIALOG = 'updates/SHOW_UPDATE_DIALOG';
const SNOOZE_UPDATE = 'updates/SNOOZE_UPDATE';
const START_UPDATE = 'updates/START_UPDATE';
const CHECK_FOR_UPDATES = 'updates/CHECK_FOR_UPDATES';
const CHECK_FOR_UPDATES_FINISHED = 'updates/CHECK_FOR_UPDATES_FINISHED';
const UNSNOOZE_UPDATE = 'updates/UNSNOOZE_UPDATE';
export type UpdateDialogOptionsType = ReadonlyDeep<{
@ -55,6 +58,14 @@ type StartUpdateActionType = ReadonlyDeep<{
type: typeof START_UPDATE;
}>;
type CheckForUpdatesActionType = ReadonlyDeep<{
type: typeof CHECK_FOR_UPDATES;
}>;
type CheckForUpdatesFinishedActionType = ReadonlyDeep<{
type: typeof CHECK_FOR_UPDATES_FINISHED;
}>;
type UnsnoozeUpdateActionType = ReadonlyDeep<{
type: typeof UNSNOOZE_UPDATE;
payload: DialogType;
@ -65,6 +76,8 @@ export type UpdatesActionType = ReadonlyDeep<
| ShowUpdateDialogActionType
| SnoozeUpdateActionType
| StartUpdateActionType
| CheckForUpdatesActionType
| CheckForUpdatesFinishedActionType
| UnsnoozeUpdateActionType
>;
@ -135,11 +148,43 @@ function startUpdate(): ThunkAction<
};
}
function forceUpdate(): ThunkAction<
void,
RootStateType,
unknown,
| CheckForUpdatesActionType
| CheckForUpdatesFinishedActionType
| ShowUpdateDialogActionType
> {
return async dispatch => {
dispatch({
type: CHECK_FOR_UPDATES,
});
try {
await updateIpc.forceUpdate();
} catch {
dispatch({
type: SHOW_UPDATE_DIALOG,
payload: {
dialogType: DialogType.Cannot_Update,
otherState: {},
},
});
} finally {
dispatch({
type: CHECK_FOR_UPDATES_FINISHED,
});
}
};
}
export const actions = {
dismissDialog,
showUpdateDialog,
snoozeUpdate,
startUpdate,
forceUpdate,
};
export const useUpdatesActions = (): BoundActionCreatorsMapObject<
@ -152,6 +197,7 @@ export function getEmptyState(): UpdatesStateType {
return {
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: false,
showEventsCount: 0,
};
}
@ -187,6 +233,22 @@ export function reducer(
};
}
if (action.type === CHECK_FOR_UPDATES) {
return {
...state,
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: true,
};
}
if (action.type === CHECK_FOR_UPDATES_FINISHED) {
return {
...state,
isCheckingForUpdates: false,
};
}
if (
action.type === DISMISS_DIALOG &&
state.dialogType === DialogType.MacOS_Read_Only

View file

@ -32,7 +32,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const { openInbox } = useAppActions();
const { startInstaller, finishInstall, retryBackupImport } =
useInstallerActions();
const { startUpdate } = useUpdatesActions();
const { startUpdate, forceUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
const [deviceName, setDeviceName] = useState<string>('');
@ -80,6 +80,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
updates,
currentVersion: window.getVersion(),
startUpdate,
forceUpdate,
retryGetQrCode: startInstaller,
OS: OS.getName(),
isStaging: isStagingServer(),
@ -112,9 +113,15 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
backupStep: installerState.backupStep,
currentBytes: installerState.currentBytes,
totalBytes: installerState.totalBytes,
hasError: installerState.hasError,
error: installerState.error,
onCancel: onCancelBackupImport,
onRetry: retryBackupImport,
updates,
currentVersion: window.getVersion(),
forceUpdate,
startUpdate,
OS: OS.getName(),
},
};
break;

View file

@ -17,6 +17,11 @@ export enum InstallScreenBackupStep {
Process = 'Process',
}
export enum InstallScreenBackupError {
Unknown = 'Unknown',
UnsupportedVersion = 'UnsupportedVersion',
}
export enum InstallScreenError {
TooManyDevices = 'TooManyDevices',
TooOld = 'TooOld',

View file

@ -155,6 +155,8 @@ export abstract class Updater {
},
50
);
ipcMain.handle('updater/force-update', () => this.force());
}
//