mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Update local backup export UI
This commit is contained in:
@@ -7480,6 +7480,30 @@
|
||||
"messageformat": "Your chat history can’t be exported because Signal doesn’t have permission to write files to disk. Try changing your disk's permissions or updating your system settings and then export again.",
|
||||
"description": "Detail text in dialog shown when we attempted to save a file during export but got a permissions error"
|
||||
},
|
||||
"icu:LocalBackupExport--ProgressDialog--Header": {
|
||||
"messageformat": "Exporting backup",
|
||||
"description": "Title of a progress dialog that shows while exporting a local backup"
|
||||
},
|
||||
"icu:LocalBackupExport--CompleteDialog--Header": {
|
||||
"messageformat": "Backup export complete",
|
||||
"description": "Title of the dialog shown once the export is complete"
|
||||
},
|
||||
"icu:LocalBackupExport--CompleteDialog--RestoreInstructionsHeader": {
|
||||
"messageformat": "To restore this backup:",
|
||||
"description": "Shown above a list of three bullet-point instructions that show users how to restore their chat history from this backup on their phone"
|
||||
},
|
||||
"icu:LocalBackupExport--CompleteDialog--RestoreInstructionsTransfer": {
|
||||
"messageformat": "Transfer your backup folder to your phone's storage",
|
||||
"description": "First step in instructions for users on how to restore their local chat history backup onto their phone"
|
||||
},
|
||||
"icu:LocalBackupExport--CompleteDialog--RestoreInstructionsInstall": {
|
||||
"messageformat": "Install a new copy of Signal on your phone",
|
||||
"description": "Second step in instructions for users on how to restore their chat history backup onto their phone"
|
||||
},
|
||||
"icu:LocalBackupExport--CompleteDialog--RestoreInstructionsRestore": {
|
||||
"messageformat": "Tap “Restore or Transfer” and choose “On Device Backup”",
|
||||
"description": "Third step in instructions for users on how to restore their chat history backup onto their phone"
|
||||
},
|
||||
"icu:NotificationProfile--moon-icon": {
|
||||
"messageformat": "Moon icon",
|
||||
"description": "Screenreader description for the moon icon used to signify notification profiles"
|
||||
@@ -7956,6 +7980,18 @@
|
||||
"messageformat": "See key again",
|
||||
"description": "Link text to return to the previous backup key page, shown when confirming the backup key for local message backups."
|
||||
},
|
||||
"icu:Preferences__local-backups-last-backup": {
|
||||
"messageformat": "Last backup",
|
||||
"description": "Label for the last backup date in local on-device backups settings."
|
||||
},
|
||||
"icu:Preferences__local-backups-last-backup-never": {
|
||||
"messageformat": "Never",
|
||||
"description": "Text shown when no local backup has been created yet."
|
||||
},
|
||||
"icu:Preferences__local-backups-backup-now": {
|
||||
"messageformat": "Back up now",
|
||||
"description": "Button to trigger a local backup immediately."
|
||||
},
|
||||
"icu:Preferences__local-backups-folder": {
|
||||
"messageformat": "Backup folder",
|
||||
"description": "Label for current folder in which local on-device backups are stored."
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -204,7 +204,7 @@ export function getCI({
|
||||
}
|
||||
|
||||
async function exportLocalBackup(backupsBaseDir: string): Promise<string> {
|
||||
const { snapshotDir } = await backupsService.exportLocalEncryptedBackup({
|
||||
const { snapshotDir } = await backupsService.exportLocalBackup({
|
||||
backupsBaseDir,
|
||||
onProgress: () => null,
|
||||
abortSignal: new AbortController().signal,
|
||||
|
||||
@@ -165,6 +165,9 @@ export type PropsType = {
|
||||
// PlaintextExportWorkflow
|
||||
shouldShowPlaintextExportWorkflow: boolean;
|
||||
renderPlaintextExportWorkflow: () => React.JSX.Element;
|
||||
// LocalBackupExportWorkflow
|
||||
shouldShowLocalBackupExportWorkflow: boolean;
|
||||
renderLocalBackupExportWorkflow: () => React.JSX.Element;
|
||||
};
|
||||
|
||||
export function GlobalModalContainer({
|
||||
@@ -270,6 +273,9 @@ export function GlobalModalContainer({
|
||||
// PlaintextExportWorkflow
|
||||
shouldShowPlaintextExportWorkflow,
|
||||
renderPlaintextExportWorkflow,
|
||||
// LocalBackupExportWorkflow
|
||||
shouldShowLocalBackupExportWorkflow,
|
||||
renderLocalBackupExportWorkflow,
|
||||
}: PropsType): React.JSX.Element | null {
|
||||
// We want the following dialogs to show in this order:
|
||||
// 0. Stateful multi-modal workflows
|
||||
@@ -282,6 +288,10 @@ export function GlobalModalContainer({
|
||||
return renderPlaintextExportWorkflow();
|
||||
}
|
||||
|
||||
if (shouldShowLocalBackupExportWorkflow) {
|
||||
return renderLocalBackupExportWorkflow();
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (errorModalProps) {
|
||||
return renderErrorModal(errorModalProps);
|
||||
|
||||
146
ts/components/LocalBackupExportWorkflow.dom.stories.tsx
Normal file
146
ts/components/LocalBackupExportWorkflow.dom.stories.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { LocalBackupExportWorkflow } from './LocalBackupExportWorkflow.dom.js';
|
||||
import {
|
||||
LocalExportErrors,
|
||||
LocalBackupExportSteps,
|
||||
} from '../types/LocalExport.std.js';
|
||||
|
||||
import type { PropsType } from './LocalBackupExportWorkflow.dom.js';
|
||||
import type { ComponentMeta } from '../storybook/types.std.js';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
export default {
|
||||
title: 'Components/LocalBackupExportWorkflow',
|
||||
component: LocalBackupExportWorkflow,
|
||||
args: {
|
||||
cancelWorkflow: action('cancelWorkflow'),
|
||||
clearWorkflow: action('clearWorkflow'),
|
||||
i18n,
|
||||
openFileInFolder: action('openFileInFolder'),
|
||||
osName: undefined,
|
||||
workflow: {
|
||||
step: LocalBackupExportSteps.ExportingMessages,
|
||||
localBackupFolder: 'backups',
|
||||
abortController: new AbortController(),
|
||||
},
|
||||
},
|
||||
} satisfies ComponentMeta<PropsType>;
|
||||
|
||||
export function ExportingMessages(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.ExportingMessages,
|
||||
abortController: new AbortController(),
|
||||
localBackupFolder: '/somewhere',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportingAttachments(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.ExportingAttachments,
|
||||
abortController: new AbortController(),
|
||||
localBackupFolder: '/somewhere',
|
||||
progress: {
|
||||
totalBytes: 1000000,
|
||||
currentBytes: 500000,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompleteMac(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
osName="macos"
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Complete,
|
||||
localBackupFolder: '/somewhere',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompleteLinux(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
osName="windows"
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Complete,
|
||||
localBackupFolder: '/somewhere',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorGeneric(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: LocalExportErrors.General,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorNotEnoughStorage(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: LocalExportErrors.NotEnoughStorage,
|
||||
bytesNeeded: 12000000,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorRanOutOfStorage(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: LocalExportErrors.RanOutOfStorage,
|
||||
bytesNeeded: 12000000,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorStoragePermissions(args: PropsType): React.JSX.Element {
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
{...args}
|
||||
workflow={{
|
||||
step: LocalBackupExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: LocalExportErrors.StoragePermissions,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
269
ts/components/LocalBackupExportWorkflow.dom.tsx
Normal file
269
ts/components/LocalBackupExportWorkflow.dom.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
LocalExportErrors,
|
||||
LocalBackupExportSteps,
|
||||
} from '../types/LocalExport.std.js';
|
||||
import { AxoDialog } from '../axo/AxoDialog.dom.js';
|
||||
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
|
||||
|
||||
import type { LocalBackupExportWorkflowType } from '../types/LocalExport.std.js';
|
||||
import type { LocalizerType } from '../types/I18N.std.js';
|
||||
import { formatFileSize } from '../util/formatFileSize.std.js';
|
||||
import { ProgressBar } from './ProgressBar.dom.js';
|
||||
import { missingCaseError } from '../util/missingCaseError.std.js';
|
||||
import { tw } from '../axo/tw.dom.js';
|
||||
import { AxoSymbol } from '../axo/AxoSymbol.dom.js';
|
||||
import type { AxoSymbolIconName } from '../axo/_internal/AxoSymbolDefs.generated.std.js';
|
||||
|
||||
export type PropsType = {
|
||||
cancelWorkflow: () => void;
|
||||
clearWorkflow: () => void;
|
||||
i18n: LocalizerType;
|
||||
openFileInFolder: (path: string) => void;
|
||||
osName: 'linux' | 'macos' | 'windows' | undefined;
|
||||
workflow: LocalBackupExportWorkflowType;
|
||||
};
|
||||
|
||||
export function LocalBackupExportWorkflow({
|
||||
cancelWorkflow,
|
||||
clearWorkflow,
|
||||
i18n,
|
||||
openFileInFolder,
|
||||
osName,
|
||||
workflow,
|
||||
}: PropsType): React.JSX.Element {
|
||||
const { step } = workflow;
|
||||
|
||||
if (
|
||||
step === LocalBackupExportSteps.ExportingMessages ||
|
||||
step === LocalBackupExportSteps.ExportingAttachments
|
||||
) {
|
||||
const progress =
|
||||
step === LocalBackupExportSteps.ExportingAttachments
|
||||
? workflow.progress
|
||||
: undefined;
|
||||
|
||||
let progressElements;
|
||||
if (progress) {
|
||||
const fractionComplete =
|
||||
progress.totalBytes > 0
|
||||
? progress.currentBytes / progress.totalBytes
|
||||
: 0;
|
||||
|
||||
progressElements = (
|
||||
<>
|
||||
<div className={tw('mb-[17px]')}>
|
||||
<ProgressBar
|
||||
fractionComplete={fractionComplete}
|
||||
isRTL={i18n.getLocaleDirection() === 'rtl'}
|
||||
/>
|
||||
</div>
|
||||
<div className={tw('mb-1.5 text-center type-body-small font-[600]')}>
|
||||
{i18n('icu:PlaintextExport--ProgressDialog--Progress', {
|
||||
currentBytes: formatFileSize(progress.currentBytes),
|
||||
totalBytes: formatFileSize(progress.totalBytes),
|
||||
percentage: fractionComplete,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
progressElements = (
|
||||
<div className={tw('mb-[17px]')}>
|
||||
<ProgressBar
|
||||
fractionComplete={null}
|
||||
isRTL={i18n.getLocaleDirection() === 'rtl'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AxoDialog.Root open onOpenChange={cancelWorkflow}>
|
||||
<AxoDialog.Content size="md" escape="cancel-is-destructive">
|
||||
<AxoDialog.Header>
|
||||
<AxoDialog.Title>
|
||||
<div className={tw('pt-[10px]')}>
|
||||
{i18n('icu:LocalBackupExport--ProgressDialog--Header')}
|
||||
</div>
|
||||
</AxoDialog.Title>
|
||||
</AxoDialog.Header>
|
||||
<AxoDialog.Body padding="normal">
|
||||
<div className={tw('mx-auto my-[29px] w-[331px]')}>
|
||||
{progressElements}
|
||||
<div
|
||||
className={tw(
|
||||
'text-center type-body-small text-label-secondary'
|
||||
)}
|
||||
>
|
||||
{i18n('icu:PlaintextExport--ProgressDialog--TimeWarning')}
|
||||
</div>
|
||||
</div>
|
||||
</AxoDialog.Body>
|
||||
<AxoDialog.Footer>
|
||||
<div
|
||||
className={tw(
|
||||
'mx-auto',
|
||||
'flex flex-wrap',
|
||||
'max-w-full',
|
||||
'items-center gap-x-2 gap-y-3'
|
||||
)}
|
||||
>
|
||||
<AxoDialog.Action variant="secondary" onClick={cancelWorkflow}>
|
||||
{i18n('icu:cancel')}
|
||||
</AxoDialog.Action>
|
||||
</div>
|
||||
</AxoDialog.Footer>
|
||||
</AxoDialog.Content>
|
||||
</AxoDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === LocalBackupExportSteps.Complete) {
|
||||
let showInFolderText = i18n(
|
||||
'icu:PlaintextExport--CompleteDialog--ShowFiles--Windows'
|
||||
);
|
||||
if (osName === 'macos') {
|
||||
showInFolderText = i18n(
|
||||
'icu:PlaintextExport--CompleteDialog--ShowFiles--Mac'
|
||||
);
|
||||
} else if (osName === 'linux') {
|
||||
showInFolderText = i18n(
|
||||
'icu:PlaintextExport--CompleteDialog--ShowFiles--Linux'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AxoAlertDialog.Root open onOpenChange={clearWorkflow}>
|
||||
<AxoAlertDialog.Content escape="cancel-is-noop">
|
||||
<AxoAlertDialog.Body>
|
||||
<div className={tw('flex flex-col items-center')}>
|
||||
<img
|
||||
src="images/desktop-and-phone.svg"
|
||||
className={tw('my-4')}
|
||||
height="61"
|
||||
width="90"
|
||||
alt=""
|
||||
/>
|
||||
<AxoAlertDialog.Title>
|
||||
<div className={tw('mb-3 type-title-medium')}>
|
||||
{i18n('icu:LocalBackupExport--CompleteDialog--Header')}
|
||||
</div>
|
||||
</AxoAlertDialog.Title>
|
||||
</div>
|
||||
<AxoAlertDialog.Description>
|
||||
<div className={tw('mb-5 flex flex-col gap-5')}>
|
||||
{i18n(
|
||||
'icu:LocalBackupExport--CompleteDialog--RestoreInstructionsHeader'
|
||||
)}
|
||||
<ol className={tw('flex flex-col gap-5')}>
|
||||
<ListItemWithIcon
|
||||
iconName="sort-vertical"
|
||||
content={i18n(
|
||||
'icu:LocalBackupExport--CompleteDialog--RestoreInstructionsTransfer'
|
||||
)}
|
||||
/>
|
||||
<ListItemWithIcon
|
||||
iconName="device-phone"
|
||||
content={i18n(
|
||||
'icu:LocalBackupExport--CompleteDialog--RestoreInstructionsInstall'
|
||||
)}
|
||||
/>
|
||||
<ListItemWithIcon
|
||||
iconName="folder"
|
||||
content={i18n(
|
||||
'icu:LocalBackupExport--CompleteDialog--RestoreInstructionsRestore'
|
||||
)}
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</AxoAlertDialog.Description>
|
||||
</AxoAlertDialog.Body>
|
||||
<AxoAlertDialog.Footer>
|
||||
<AxoAlertDialog.Action
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
openFileInFolder(workflow.localBackupFolder);
|
||||
clearWorkflow();
|
||||
}}
|
||||
>
|
||||
{showInFolderText}
|
||||
</AxoAlertDialog.Action>
|
||||
<AxoAlertDialog.Action variant="primary" onClick={clearWorkflow}>
|
||||
{i18n('icu:ok')}
|
||||
</AxoAlertDialog.Action>
|
||||
</AxoAlertDialog.Footer>
|
||||
</AxoAlertDialog.Content>
|
||||
</AxoAlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === LocalBackupExportSteps.Error) {
|
||||
const { type } = workflow.errorDetails;
|
||||
let title;
|
||||
let detail;
|
||||
|
||||
if (type === LocalExportErrors.General) {
|
||||
title = i18n('icu:PlaintextExport--Error--General--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--General--Description');
|
||||
} else if (type === LocalExportErrors.NotEnoughStorage) {
|
||||
title = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Detail', {
|
||||
bytes: formatFileSize(workflow.errorDetails.bytesNeeded),
|
||||
});
|
||||
} else if (type === LocalExportErrors.RanOutOfStorage) {
|
||||
title = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Detail', {
|
||||
bytes: formatFileSize(workflow.errorDetails.bytesNeeded),
|
||||
});
|
||||
} else if (type === LocalExportErrors.StoragePermissions) {
|
||||
title = i18n('icu:PlaintextExport--Error--DiskPermssions--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--DiskPermssions--Detail');
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<AxoAlertDialog.Root open onOpenChange={clearWorkflow}>
|
||||
<AxoAlertDialog.Content escape="cancel-is-destructive">
|
||||
<AxoAlertDialog.Body>
|
||||
<AxoAlertDialog.Title>{title}</AxoAlertDialog.Title>
|
||||
<AxoAlertDialog.Description>{detail}</AxoAlertDialog.Description>
|
||||
</AxoAlertDialog.Body>
|
||||
<AxoAlertDialog.Footer>
|
||||
<AxoAlertDialog.Action variant="primary" onClick={clearWorkflow}>
|
||||
{i18n('icu:ok')}
|
||||
</AxoAlertDialog.Action>
|
||||
</AxoAlertDialog.Footer>
|
||||
</AxoAlertDialog.Content>
|
||||
</AxoAlertDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
throw missingCaseError(step);
|
||||
}
|
||||
|
||||
function ListItemWithIcon({
|
||||
iconName,
|
||||
content,
|
||||
}: {
|
||||
iconName: AxoSymbolIconName;
|
||||
content: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<li className={tw('flex items-center gap-2')}>
|
||||
<div
|
||||
className={tw(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-full bg-fill-secondary'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={20} symbol={iconName} label={null} />
|
||||
</div>
|
||||
<div className={tw('text-start')}>{content}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function LocalDeleteWarningModal({
|
||||
<div className="LocalDeleteWarningModal">
|
||||
<div className="LocalDeleteWarningModal__image">
|
||||
<img
|
||||
src="images/local-delete-sync.svg"
|
||||
src="images/desktop-and-phone.svg"
|
||||
height="92"
|
||||
width="138"
|
||||
alt=""
|
||||
|
||||
@@ -5,9 +5,9 @@ import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { PlaintextExportWorkflow } from './PlaintextExportWorkflow.dom.js';
|
||||
import {
|
||||
PlaintextExportErrors,
|
||||
LocalExportErrors,
|
||||
PlaintextExportSteps,
|
||||
} from '../types/Backups.std.js';
|
||||
} from '../types/LocalExport.std.js';
|
||||
|
||||
import type { PropsType } from './PlaintextExportWorkflow.dom.js';
|
||||
import type { ComponentMeta } from '../storybook/types.std.js';
|
||||
@@ -65,7 +65,6 @@ export function ExportingMessages(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.ExportingMessages,
|
||||
abortController: new AbortController(),
|
||||
exportInBackground: false,
|
||||
exportPath: '/somewhere',
|
||||
}}
|
||||
/>
|
||||
@@ -79,7 +78,6 @@ export function ExportingAttachments(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.ExportingAttachments,
|
||||
abortController: new AbortController(),
|
||||
exportInBackground: false,
|
||||
exportPath: '/somewhere',
|
||||
progress: {
|
||||
totalBytes: 1000000,
|
||||
@@ -123,7 +121,7 @@ export function ErrorGeneric(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: PlaintextExportErrors.General,
|
||||
type: LocalExportErrors.General,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -137,7 +135,7 @@ export function ErrorNotEnoughStorage(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: PlaintextExportErrors.NotEnoughStorage,
|
||||
type: LocalExportErrors.NotEnoughStorage,
|
||||
bytesNeeded: 12000000,
|
||||
},
|
||||
}}
|
||||
@@ -152,7 +150,7 @@ export function ErrorRanOutOfStorage(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: PlaintextExportErrors.RanOutOfStorage,
|
||||
type: LocalExportErrors.RanOutOfStorage,
|
||||
bytesNeeded: 12000000,
|
||||
},
|
||||
}}
|
||||
@@ -167,7 +165,7 @@ export function ErrorStoragePermissions(args: PropsType): React.JSX.Element {
|
||||
workflow={{
|
||||
step: PlaintextExportSteps.Error,
|
||||
errorDetails: {
|
||||
type: PlaintextExportErrors.StoragePermissions,
|
||||
type: LocalExportErrors.StoragePermissions,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
PlaintextExportErrors,
|
||||
LocalExportErrors,
|
||||
PlaintextExportSteps,
|
||||
} from '../types/Backups.std.js';
|
||||
} from '../types/LocalExport.std.js';
|
||||
import { AxoDialog } from '../axo/AxoDialog.dom.js';
|
||||
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
|
||||
|
||||
import type { PlaintextExportWorkflowType } from '../types/Backups.std.js';
|
||||
import type { PlaintextExportWorkflowType } from '../types/LocalExport.std.js';
|
||||
import type { LocalizerType } from '../types/I18N.std.js';
|
||||
import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js';
|
||||
import { formatFileSize } from '../util/formatFileSize.std.js';
|
||||
@@ -265,20 +265,20 @@ export function PlaintextExportWorkflow({
|
||||
let title;
|
||||
let detail;
|
||||
|
||||
if (type === PlaintextExportErrors.General) {
|
||||
if (type === LocalExportErrors.General) {
|
||||
title = i18n('icu:PlaintextExport--Error--General--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--General--Description');
|
||||
} else if (type === PlaintextExportErrors.NotEnoughStorage) {
|
||||
} else if (type === LocalExportErrors.NotEnoughStorage) {
|
||||
title = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Detail', {
|
||||
bytes: formatFileSize(workflow.errorDetails.bytesNeeded),
|
||||
});
|
||||
} else if (type === PlaintextExportErrors.RanOutOfStorage) {
|
||||
} else if (type === LocalExportErrors.RanOutOfStorage) {
|
||||
title = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Detail', {
|
||||
bytes: formatFileSize(workflow.errorDetails.bytesNeeded),
|
||||
});
|
||||
} else if (type === PlaintextExportErrors.StoragePermissions) {
|
||||
} else if (type === LocalExportErrors.StoragePermissions) {
|
||||
title = i18n('icu:PlaintextExport--Error--DiskPermssions--Title');
|
||||
detail = i18n('icu:PlaintextExport--Error--DiskPermssions--Detail');
|
||||
} else {
|
||||
|
||||
@@ -58,10 +58,7 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre
|
||||
import { CurrentChatFolders } from '../types/CurrentChatFolders.std.js';
|
||||
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js';
|
||||
import type { NotificationProfileIdString } from '../types/NotificationProfile.std.js';
|
||||
import type {
|
||||
ExportResultType,
|
||||
LocalBackupExportResultType,
|
||||
} from '../services/backups/types.std.js';
|
||||
import type { ExportResultType } from '../services/backups/types.std.js';
|
||||
import { BackupLevel } from '../services/backups/types.std.js';
|
||||
|
||||
const { shuffle } = lodash;
|
||||
@@ -135,11 +132,6 @@ const validateBackupResult: ExportResultType = {
|
||||
},
|
||||
};
|
||||
|
||||
const exportLocalBackupResult: LocalBackupExportResultType = {
|
||||
...validateBackupResult,
|
||||
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
|
||||
};
|
||||
|
||||
const donationAmountsConfig = {
|
||||
cad: {
|
||||
minimum: 4,
|
||||
@@ -468,6 +460,7 @@ export default {
|
||||
isContentProtectionNeeded: true,
|
||||
isMinimizeToAndStartInSystemTraySupported: true,
|
||||
isPlaintextExportEnabled: true,
|
||||
lastLocalBackup: undefined,
|
||||
lastSyncTime: Date.now(),
|
||||
localeOverride: null,
|
||||
localBackupFolder: undefined,
|
||||
@@ -538,11 +531,6 @@ export default {
|
||||
addCustomColor: action('addCustomColor'),
|
||||
doDeleteAllData: action('doDeleteAllData'),
|
||||
editCustomColor: action('editCustomColor'),
|
||||
exportLocalBackup: async () => {
|
||||
return {
|
||||
result: exportLocalBackupResult,
|
||||
};
|
||||
},
|
||||
getMessageCountBySchemaVersion: async () => [
|
||||
{ schemaVersion: 10, count: 1024 },
|
||||
{ schemaVersion: 8, count: 256 },
|
||||
@@ -616,6 +604,7 @@ export default {
|
||||
),
|
||||
setSettingsLocation: action('setSettingsLocation'),
|
||||
showToast: action('showToast'),
|
||||
startLocalBackupExport: action('startLocalBackupExport'),
|
||||
startPlaintextExport: action('startPlaintextExport'),
|
||||
validateBackup: async () => {
|
||||
return {
|
||||
@@ -1210,6 +1199,21 @@ LocalBackups.args = {
|
||||
backupFeatureEnabled: true,
|
||||
backupLocalBackupsEnabled: true,
|
||||
backupKeyViewed: true,
|
||||
lastLocalBackup: {
|
||||
timestamp: Date.now() - DAY,
|
||||
backupsFolder: 'backups',
|
||||
snapshotDir: 'backups/snapshot',
|
||||
},
|
||||
localBackupFolder: '/home/signaluser/Signal Backups/',
|
||||
};
|
||||
|
||||
export const LocalBackupsNeverBackedUp = Template.bind({});
|
||||
LocalBackupsNeverBackedUp.args = {
|
||||
settingsLocation: { page: SettingsPage.LocalBackups },
|
||||
backupFeatureEnabled: true,
|
||||
backupLocalBackupsEnabled: true,
|
||||
backupKeyViewed: true,
|
||||
lastLocalBackup: undefined,
|
||||
localBackupFolder: '/home/signaluser/Signal Backups/',
|
||||
};
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre
|
||||
import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.preload.js';
|
||||
import { AxoButton } from '../axo/AxoButton.dom.js';
|
||||
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js';
|
||||
import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js';
|
||||
|
||||
const { isNumber, noop, partition } = lodash;
|
||||
|
||||
@@ -113,6 +114,7 @@ export type PropsDataType = {
|
||||
backupKeyViewed: boolean;
|
||||
backupLocalBackupsEnabled: boolean;
|
||||
backupTier: BackupLevel | null;
|
||||
lastLocalBackup: LocalBackupExportMetadata | undefined;
|
||||
localBackupFolder: string | undefined;
|
||||
chatFoldersFeatureEnabled: boolean;
|
||||
currentChatFoldersCount: number;
|
||||
@@ -240,7 +242,6 @@ type PropsFunctionType = {
|
||||
addCustomColor: (color: CustomColorType) => unknown;
|
||||
doDeleteAllData: () => unknown;
|
||||
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
|
||||
exportLocalBackup: () => Promise<BackupValidationResultType>;
|
||||
getMessageCountBySchemaVersion: () => Promise<MessageCountBySchemaVersionType>;
|
||||
getMessageSampleForSchemaVersion: (
|
||||
version: number
|
||||
@@ -270,6 +271,7 @@ type PropsFunctionType = {
|
||||
) => unknown;
|
||||
setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
|
||||
showToast: (toast: AnyToast) => unknown;
|
||||
startLocalBackupExport: () => void;
|
||||
startPlaintextExport: () => unknown;
|
||||
validateBackup: () => Promise<BackupValidationResultType>;
|
||||
|
||||
@@ -405,7 +407,6 @@ export function Preferences({
|
||||
doDeleteAllData,
|
||||
editCustomColor,
|
||||
emojiSkinToneDefault,
|
||||
exportLocalBackup,
|
||||
getConversationsWithCustomColor,
|
||||
getMessageCountBySchemaVersion,
|
||||
getMessageSampleForSchemaVersion,
|
||||
@@ -449,6 +450,7 @@ export function Preferences({
|
||||
isSystemTraySupported,
|
||||
isMinimizeToAndStartInSystemTraySupported,
|
||||
isInternalUser,
|
||||
lastLocalBackup,
|
||||
lastSyncTime,
|
||||
localBackupFolder,
|
||||
makeSyncRequest,
|
||||
@@ -525,6 +527,7 @@ export function Preferences({
|
||||
setSettingsLocation,
|
||||
shouldShowUpdateDialog,
|
||||
showToast,
|
||||
startLocalBackupExport,
|
||||
startPlaintextExport,
|
||||
localeOverride,
|
||||
theme,
|
||||
@@ -2243,6 +2246,7 @@ export function Preferences({
|
||||
i18n={i18n}
|
||||
isLocalBackupsEnabled={backupLocalBackupsEnabled}
|
||||
isRemoteBackupsEnabled={backupFeatureEnabled}
|
||||
lastLocalBackup={lastLocalBackup}
|
||||
locale={resolvedLocale}
|
||||
localBackupFolder={localBackupFolder}
|
||||
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
||||
@@ -2253,6 +2257,7 @@ export function Preferences({
|
||||
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
showToast={showToast}
|
||||
startLocalBackupExport={startLocalBackupExport}
|
||||
/>
|
||||
);
|
||||
content = (
|
||||
@@ -2281,7 +2286,6 @@ export function Preferences({
|
||||
contents={
|
||||
<PreferencesInternal
|
||||
i18n={i18n}
|
||||
exportLocalBackup={exportLocalBackup}
|
||||
validateBackup={validateBackup}
|
||||
getMessageCountBySchemaVersion={getMessageCountBySchemaVersion}
|
||||
getMessageSampleForSchemaVersion={getMessageSampleForSchemaVersion}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
BackupsDetailsPage,
|
||||
renderSubscriptionDetails,
|
||||
} from './PreferencesBackupDetails.dom.js';
|
||||
import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js';
|
||||
|
||||
export const SIGNAL_BACKUPS_LEARN_MORE_URL =
|
||||
'https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages';
|
||||
@@ -61,6 +62,7 @@ export function PreferencesBackups({
|
||||
i18n,
|
||||
isLocalBackupsEnabled,
|
||||
isRemoteBackupsEnabled,
|
||||
lastLocalBackup,
|
||||
locale,
|
||||
localBackupFolder,
|
||||
onBackupKeyViewedChange,
|
||||
@@ -75,6 +77,7 @@ export function PreferencesBackups({
|
||||
refreshBackupSubscriptionStatus,
|
||||
setSettingsLocation,
|
||||
showToast,
|
||||
startLocalBackupExport,
|
||||
}: {
|
||||
accountEntropyPool: string | undefined;
|
||||
backupFreeMediaDays: number;
|
||||
@@ -86,6 +89,7 @@ export function PreferencesBackups({
|
||||
i18n: LocalizerType;
|
||||
isLocalBackupsEnabled: boolean;
|
||||
isRemoteBackupsEnabled: boolean;
|
||||
lastLocalBackup: LocalBackupExportMetadata | undefined;
|
||||
locale: string;
|
||||
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
||||
settingsLocation: SettingsLocation;
|
||||
@@ -101,6 +105,7 @@ export function PreferencesBackups({
|
||||
refreshBackupSubscriptionStatus: () => void;
|
||||
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||
showToast: ShowToastAction;
|
||||
startLocalBackupExport: () => void;
|
||||
}): React.JSX.Element | null {
|
||||
const [authError, setAuthError] =
|
||||
useState<Omit<PromptOSAuthResultType, 'success'>>();
|
||||
@@ -156,6 +161,7 @@ export function PreferencesBackups({
|
||||
accountEntropyPool={accountEntropyPool}
|
||||
backupKeyViewed={backupKeyViewed}
|
||||
i18n={i18n}
|
||||
lastLocalBackup={lastLocalBackup}
|
||||
localBackupFolder={localBackupFolder}
|
||||
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
||||
settingsLocation={settingsLocation}
|
||||
@@ -163,6 +169,7 @@ export function PreferencesBackups({
|
||||
promptOSAuth={promptOSAuth}
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
showToast={showToast}
|
||||
startLocalBackupExport={startLocalBackupExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const log = createLogger('PreferencesInternal');
|
||||
|
||||
export function PreferencesInternal({
|
||||
i18n,
|
||||
exportLocalBackup: doExportLocalBackup,
|
||||
validateBackup: doValidateBackup,
|
||||
getMessageCountBySchemaVersion,
|
||||
getMessageSampleForSchemaVersion,
|
||||
@@ -40,7 +39,6 @@ export function PreferencesInternal({
|
||||
setCallQualitySurveyCooldownDisabled,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
exportLocalBackup: () => Promise<BackupValidationResultType>;
|
||||
validateBackup: () => Promise<BackupValidationResultType>;
|
||||
getMessageCountBySchemaVersion: () => Promise<MessageCountBySchemaVersionType>;
|
||||
getMessageSampleForSchemaVersion: (
|
||||
@@ -64,11 +62,6 @@ export function PreferencesInternal({
|
||||
callQualitySurveyCooldownDisabled: boolean;
|
||||
setCallQualitySurveyCooldownDisabled: (value: boolean) => void;
|
||||
}): React.JSX.Element {
|
||||
const [isExportPending, setIsExportPending] = useState(false);
|
||||
const [exportResult, setExportResult] = useState<
|
||||
BackupValidationResultType | undefined
|
||||
>();
|
||||
|
||||
const [messageCountBySchemaVersion, setMessageCountBySchemaVersion] =
|
||||
useState<MessageCountBySchemaVersionType>();
|
||||
const [messageSampleForVersions, setMessageSampleForVersions] = useState<{
|
||||
@@ -151,18 +144,6 @@ export function PreferencesInternal({
|
||||
[]
|
||||
);
|
||||
|
||||
const exportLocalBackup = useCallback(async () => {
|
||||
setIsExportPending(true);
|
||||
setExportResult(undefined);
|
||||
try {
|
||||
setExportResult(await doExportLocalBackup());
|
||||
} catch (error) {
|
||||
setExportResult({ error: toLogFormat(error) });
|
||||
} finally {
|
||||
setIsExportPending(false);
|
||||
}
|
||||
}, [doExportLocalBackup]);
|
||||
|
||||
// Donation receipt states
|
||||
const [isGeneratingReceipt, setIsGeneratingReceipt] = useState(false);
|
||||
|
||||
@@ -269,40 +250,6 @@ export function PreferencesInternal({
|
||||
{renderValidationResult(validationResult)}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
className="Preferences--internal--backups"
|
||||
title={i18n('icu:Preferences__internal__local-backups')}
|
||||
>
|
||||
<FlowingSettingsControl>
|
||||
<div className="Preferences__two-thirds-flow">
|
||||
{i18n(
|
||||
'icu:Preferences__internal__export-local-backup--description'
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'Preferences__flow-button',
|
||||
'Preferences__one-third-flow',
|
||||
'Preferences__one-third-flow--align-right'
|
||||
)}
|
||||
>
|
||||
<AxoButton.Root
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={exportLocalBackup}
|
||||
disabled={isExportPending}
|
||||
experimentalSpinner={
|
||||
isExportPending ? { 'aria-label': i18n('icu:loading') } : null
|
||||
}
|
||||
>
|
||||
{i18n('icu:Preferences__internal__export-local-backup')}
|
||||
</AxoButton.Root>
|
||||
</div>
|
||||
</FlowingSettingsControl>
|
||||
|
||||
{renderValidationResult(exportResult)}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
className="Preferences--internal--message-schemas"
|
||||
title="Message schema versions"
|
||||
@@ -327,7 +274,6 @@ export function PreferencesInternal({
|
||||
);
|
||||
setMessageSampleForVersions({});
|
||||
}}
|
||||
disabled={isExportPending}
|
||||
>
|
||||
Fetch data
|
||||
</AxoButton.Root>
|
||||
@@ -364,7 +310,6 @@ export function PreferencesInternal({
|
||||
[schemaVersion]: sampleMessages,
|
||||
});
|
||||
}}
|
||||
disabled={isExportPending}
|
||||
>
|
||||
Sample
|
||||
</button>
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
|
||||
import { AxoButton } from '../axo/AxoButton.dom.js';
|
||||
import { SECOND } from '../util/durations/constants.std.js';
|
||||
import { formatTimestamp } from '../util/formatTimestamp.dom.js';
|
||||
import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js';
|
||||
|
||||
const { noop } = lodash;
|
||||
|
||||
@@ -43,6 +45,7 @@ export function PreferencesLocalBackups({
|
||||
accountEntropyPool,
|
||||
backupKeyViewed,
|
||||
i18n,
|
||||
lastLocalBackup,
|
||||
localBackupFolder,
|
||||
onBackupKeyViewedChange,
|
||||
settingsLocation,
|
||||
@@ -50,10 +53,12 @@ export function PreferencesLocalBackups({
|
||||
promptOSAuth,
|
||||
setSettingsLocation,
|
||||
showToast,
|
||||
startLocalBackupExport,
|
||||
}: {
|
||||
accountEntropyPool: string | undefined;
|
||||
backupKeyViewed: boolean;
|
||||
i18n: LocalizerType;
|
||||
lastLocalBackup: LocalBackupExportMetadata | undefined;
|
||||
localBackupFolder: string | undefined;
|
||||
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
||||
settingsLocation: SettingsLocation;
|
||||
@@ -63,6 +68,7 @@ export function PreferencesLocalBackups({
|
||||
) => Promise<PromptOSAuthResultType>;
|
||||
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||
showToast: ShowToastAction;
|
||||
startLocalBackupExport: () => void;
|
||||
}): React.JSX.Element {
|
||||
const [authError, setAuthError] =
|
||||
React.useState<Omit<PromptOSAuthResultType, 'success'>>();
|
||||
@@ -107,6 +113,13 @@ export function PreferencesLocalBackups({
|
||||
</a>
|
||||
);
|
||||
|
||||
const lastBackupText = lastLocalBackup
|
||||
? formatTimestamp(lastLocalBackup.timestamp, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
: i18n('icu:Preferences__local-backups-last-backup-never');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="Preferences__padding">
|
||||
@@ -115,6 +128,29 @@ export function PreferencesLocalBackups({
|
||||
</div>
|
||||
</div>
|
||||
<SettingsRow className="Preferences--BackupsRow">
|
||||
<FlowingControl>
|
||||
<div className="Preferences__two-thirds-flow">
|
||||
<label>
|
||||
{i18n('icu:Preferences__local-backups-last-backup')}
|
||||
<div className="Preferences__description">{lastBackupText}</div>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'Preferences__flow-button',
|
||||
'Preferences__one-third-flow',
|
||||
'Preferences__one-third-flow--align-right'
|
||||
)}
|
||||
>
|
||||
<AxoButton.Root
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={startLocalBackupExport}
|
||||
>
|
||||
{i18n('icu:Preferences__local-backups-backup-now')}
|
||||
</AxoButton.Root>
|
||||
</div>
|
||||
</FlowingControl>
|
||||
<FlowingControl>
|
||||
<div className="Preferences__two-thirds-flow">
|
||||
<label>
|
||||
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
NotEnoughStorageError,
|
||||
RanOutOfStorageError,
|
||||
StoragePermissionsError,
|
||||
} from '../../types/Backups.std.js';
|
||||
} from '../../types/LocalExport.std.js';
|
||||
import { getFreeDiskSpace } from '../../util/getFreeDiskSpace.node.js';
|
||||
import { isFeaturedEnabledNoRedux } from '../../util/isFeatureEnabled.dom.js';
|
||||
|
||||
@@ -130,6 +130,8 @@ const IV_LENGTH = 16;
|
||||
|
||||
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
||||
|
||||
const MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT = 200 * MEBIBYTE;
|
||||
|
||||
export type DownloadOptionsType = Readonly<{
|
||||
onProgress?: (
|
||||
backupStep: InstallScreenBackupStep,
|
||||
@@ -337,30 +339,39 @@ export class BackupsService {
|
||||
}
|
||||
}
|
||||
|
||||
public async exportLocalEncryptedBackup(options: {
|
||||
onProgress: OnProgressCallback;
|
||||
abortSignal: AbortSignal;
|
||||
public async exportLocalBackup(options: {
|
||||
backupsBaseDir: string;
|
||||
abortSignal: AbortSignal;
|
||||
onProgress: OnProgressCallback;
|
||||
}): Promise<LocalBackupExportResultType> {
|
||||
strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled');
|
||||
const fnLog = log.child('exportLocalBackup');
|
||||
fnLog.info('starting...');
|
||||
|
||||
if (isOnline()) {
|
||||
await this.#waitForEmptyQueues('backups.exportLocalBackup');
|
||||
} else {
|
||||
log.info('exportLocalBackup: Offline; skipping wait for empty queues');
|
||||
fnLog.info('offline; skipping wait for empty queues');
|
||||
}
|
||||
|
||||
const snapshotDir = join(
|
||||
options.backupsBaseDir,
|
||||
`signal-backup-${getTimestampForFolder()}`
|
||||
);
|
||||
await mkdir(snapshotDir, { recursive: true });
|
||||
|
||||
const freeSpaceBytes = await getFreeDiskSpace(snapshotDir);
|
||||
const bytesNeeded = MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT - freeSpaceBytes;
|
||||
if (bytesNeeded > 0) {
|
||||
fnLog.info(
|
||||
`Not enough storage; only ${freeSpaceBytes} available, ${MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT} is minimum needed`
|
||||
);
|
||||
throw new NotEnoughStorageError(bytesNeeded);
|
||||
}
|
||||
|
||||
const exportResult = await this.exportToDisk(join(snapshotDir, 'main'), {
|
||||
type: 'local-encrypted',
|
||||
snapshotDir: join(
|
||||
options.backupsBaseDir,
|
||||
`signal-backup-${getTimestampForFolder()}`
|
||||
),
|
||||
snapshotDir,
|
||||
});
|
||||
|
||||
const metadataArgs = {
|
||||
@@ -380,63 +391,6 @@ export class BackupsService {
|
||||
return { ...exportResult, snapshotDir };
|
||||
}
|
||||
|
||||
public async exportLocalPlaintextBackup(options: {
|
||||
abortSignal: AbortSignal;
|
||||
onProgress: OnProgressCallback;
|
||||
shouldIncludeMedia: boolean;
|
||||
targetDir: string;
|
||||
}): Promise<ExportResultType> {
|
||||
strictAssert(
|
||||
isFeaturedEnabledNoRedux({
|
||||
betaKey: 'desktop.plaintextExport.beta',
|
||||
prodKey: 'desktop.plaintextExport.prod',
|
||||
}),
|
||||
'Plaintext export must be enabled'
|
||||
);
|
||||
|
||||
if (isOnline()) {
|
||||
await this.#waitForEmptyQueues('backups.exportLocalBackup');
|
||||
} else {
|
||||
log.info(
|
||||
'exportLocalPlaintextBackup: Offline; skipping wait for empty queues'
|
||||
);
|
||||
}
|
||||
|
||||
log.info('exportLocalPlaintextBackup: starting...');
|
||||
|
||||
await mkdir(options.targetDir, { recursive: true });
|
||||
|
||||
const exportResult = await this.exportToDisk(
|
||||
join(options.targetDir, 'main.jsonl'),
|
||||
{
|
||||
type: 'plaintext-export',
|
||||
}
|
||||
);
|
||||
|
||||
log.info('exportLocalPlaintextBackup: writing metadata');
|
||||
|
||||
const metadataPath = join(options.targetDir, 'metadata.json');
|
||||
await writeFile(
|
||||
metadataPath,
|
||||
JSON.stringify({
|
||||
version: LOCAL_BACKUP_VERSION,
|
||||
})
|
||||
);
|
||||
|
||||
if (options.shouldIncludeMedia) {
|
||||
await this.#runLocalAttachmentBackupJobs({
|
||||
attachmentBackupJobs: exportResult.attachmentBackupJobs,
|
||||
baseDir: options.targetDir,
|
||||
onProgress: options.onProgress,
|
||||
abortSignal: options.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
log.info('exportLocalPlaintextBackup: finished');
|
||||
|
||||
return exportResult;
|
||||
}
|
||||
|
||||
async #runLocalAttachmentBackupJobs({
|
||||
attachmentBackupJobs,
|
||||
baseDir,
|
||||
@@ -595,26 +549,6 @@ export class BackupsService {
|
||||
return exportResult;
|
||||
}
|
||||
|
||||
public async _internalExportLocalEncryptedBackup(): Promise<ValidationResultType> {
|
||||
try {
|
||||
const { canceled, dirPath: backupsBaseDir } = await ipcRenderer.invoke(
|
||||
'show-open-folder-dialog'
|
||||
);
|
||||
if (canceled || !backupsBaseDir) {
|
||||
return { error: 'Backups directory not selected' };
|
||||
}
|
||||
|
||||
const result = await this.exportLocalEncryptedBackup({
|
||||
backupsBaseDir,
|
||||
abortSignal: new AbortController().signal,
|
||||
onProgress: () => null,
|
||||
});
|
||||
return { result };
|
||||
} catch (error) {
|
||||
return { error: Errors.toLogFormat(error) };
|
||||
}
|
||||
}
|
||||
|
||||
public async exportPlaintext({
|
||||
abortSignal,
|
||||
onProgress,
|
||||
@@ -627,15 +561,15 @@ export class BackupsService {
|
||||
targetPath: string;
|
||||
}): Promise<LocalBackupExportResultType> {
|
||||
let exportDir: string | undefined;
|
||||
const fnLog = log.child('exportPlaintext');
|
||||
try {
|
||||
log.info('exportPlaintext starting...');
|
||||
fnLog.info('starting...');
|
||||
|
||||
const freeSpaceBytes = await getFreeDiskSpace(targetPath);
|
||||
const minimumBytes = 200 * MEBIBYTE;
|
||||
const bytesNeeded = minimumBytes - freeSpaceBytes;
|
||||
const bytesNeeded = MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT - freeSpaceBytes;
|
||||
if (bytesNeeded > 0) {
|
||||
log.info(
|
||||
`exportPlaintext: Not enough storage; only ${freeSpaceBytes} available, ${minimumBytes} is minimum needed`
|
||||
fnLog.info(
|
||||
`Not enough storage; only ${freeSpaceBytes} available, ${MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT} is minimum needed`
|
||||
);
|
||||
throw new NotEnoughStorageError(bytesNeeded);
|
||||
}
|
||||
@@ -644,24 +578,62 @@ export class BackupsService {
|
||||
|
||||
await mkdir(exportDir, { recursive: true });
|
||||
|
||||
const result = await this.exportLocalPlaintextBackup({
|
||||
targetDir: exportDir,
|
||||
abortSignal,
|
||||
onProgress,
|
||||
shouldIncludeMedia,
|
||||
});
|
||||
strictAssert(
|
||||
isFeaturedEnabledNoRedux({
|
||||
betaKey: 'desktop.plaintextExport.beta',
|
||||
prodKey: 'desktop.plaintextExport.prod',
|
||||
}),
|
||||
'Plaintext export must be enabled'
|
||||
);
|
||||
|
||||
if (isOnline()) {
|
||||
await this.#waitForEmptyQueues('backups.exportPlaintext');
|
||||
} else {
|
||||
fnLog.info('exportPlaintext: Offline; skipping wait for empty queues');
|
||||
}
|
||||
|
||||
fnLog.info('exportPlaintext: starting...');
|
||||
|
||||
await mkdir(exportDir, { recursive: true });
|
||||
|
||||
const exportResult = await this.exportToDisk(
|
||||
join(exportDir, 'main.jsonl'),
|
||||
{
|
||||
type: 'plaintext-export',
|
||||
}
|
||||
);
|
||||
|
||||
fnLog.info('exportPlaintext: writing metadata');
|
||||
|
||||
const metadataPath = join(exportDir, 'metadata.json');
|
||||
await writeFile(
|
||||
metadataPath,
|
||||
JSON.stringify({
|
||||
version: LOCAL_BACKUP_VERSION,
|
||||
})
|
||||
);
|
||||
|
||||
if (shouldIncludeMedia) {
|
||||
await this.#runLocalAttachmentBackupJobs({
|
||||
attachmentBackupJobs: exportResult.attachmentBackupJobs,
|
||||
baseDir: exportDir,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
fnLog.info('finished');
|
||||
|
||||
log.info('exportPlaintext complete!');
|
||||
return {
|
||||
...result,
|
||||
...exportResult,
|
||||
snapshotDir: exportDir,
|
||||
};
|
||||
} catch (error) {
|
||||
log.warn('exportPlaintext encountered error', Errors.toLogFormat(error));
|
||||
fnLog.warn('encountered error', Errors.toLogFormat(error));
|
||||
if (exportDir) {
|
||||
log.info('Deleting export directory');
|
||||
fnLog.info('Deleting export directory');
|
||||
await rm(exportDir, { recursive: true, force: true });
|
||||
log.info('Export directory deleted');
|
||||
fnLog.info('Export directory deleted');
|
||||
}
|
||||
|
||||
if (error.code === 'EPERM' || error.code === 'EACCES') {
|
||||
@@ -1274,7 +1246,10 @@ export class BackupsService {
|
||||
}
|
||||
|
||||
async #waitForEmptyQueues(
|
||||
reason: 'backups.upload' | 'backups.exportLocalBackup'
|
||||
reason:
|
||||
| 'backups.upload'
|
||||
| 'backups.exportPlaintext'
|
||||
| 'backups.exportLocalBackup'
|
||||
) {
|
||||
// Make sure we are up-to-date on storage service
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
import { debounce, throttle } from 'lodash';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
@@ -9,28 +9,32 @@ import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
import { createLogger } from '../../logging/log.std.js';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
|
||||
import { getBackups, getWorkflow } from '../selectors/backups.std.js';
|
||||
import { getBackups, getPlaintextWorkflow } from '../selectors/backups.std.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { promptOSAuth } from '../../util/promptOSAuth.preload.js';
|
||||
import { missingCaseError } from '../../util/missingCaseError.std.js';
|
||||
import { backupsService } from '../../services/backups/index.preload.js';
|
||||
import {
|
||||
NotEnoughStorageError,
|
||||
PlaintextExportErrors,
|
||||
LocalExportErrors,
|
||||
PlaintextExportSteps,
|
||||
RanOutOfStorageError,
|
||||
StoragePermissionsError,
|
||||
validTransitions,
|
||||
} from '../../types/Backups.std.js';
|
||||
plaintextExportValidTransitions,
|
||||
LocalBackupExportSteps,
|
||||
localBackupExportValidTransitions,
|
||||
} from '../../types/LocalExport.std.js';
|
||||
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
|
||||
import type {
|
||||
PlaintextExportErrorDetails,
|
||||
PlaintextExportWorkflowType,
|
||||
} from '../../types/Backups.std.js';
|
||||
LocalBackupExportWorkflowType,
|
||||
LocalExportErrorDetails,
|
||||
} from '../../types/LocalExport.std.js';
|
||||
import type { LocalizerType } from '../../types/I18N.std.js';
|
||||
import { toLogFormat } from '../../types/errors.std.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
|
||||
const log = createLogger('ducks/backups');
|
||||
|
||||
@@ -42,6 +46,10 @@ export type WorkflowContainer = ReadonlyDeep<
|
||||
type: 'plaintext-export';
|
||||
workflow: PlaintextExportWorkflowType;
|
||||
}
|
||||
| {
|
||||
type: 'local-backup';
|
||||
workflow: LocalBackupExportWorkflowType;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
||||
@@ -49,6 +57,24 @@ export type BackupsStateType = ReadonlyDeep<{
|
||||
workflow: WorkflowContainer;
|
||||
}>;
|
||||
|
||||
function isPlaintextExportWorkflow(
|
||||
workflow: WorkflowContainer
|
||||
): workflow is ReadonlyDeep<{
|
||||
type: 'plaintext-export';
|
||||
workflow: PlaintextExportWorkflowType;
|
||||
}> {
|
||||
return workflow?.type === 'plaintext-export';
|
||||
}
|
||||
|
||||
function isLocalBackupExportWorkflow(
|
||||
workflow: WorkflowContainer
|
||||
): workflow is ReadonlyDeep<{
|
||||
type: 'local-backup';
|
||||
workflow: LocalBackupExportWorkflowType;
|
||||
}> {
|
||||
return workflow?.type === 'local-backup';
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
const SET_WORKFLOW = 'Backup/SET_WORKFLOW';
|
||||
@@ -63,9 +89,11 @@ type BackupsActionTGype = ReadonlyDeep<SetWorkflowAction>;
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
cancelLocalBackupWorkflow,
|
||||
cancelWorkflow,
|
||||
clearWorkflow,
|
||||
setWorkflow,
|
||||
startLocalBackupExport,
|
||||
startPlaintextExport,
|
||||
verifyWithOSForExport,
|
||||
};
|
||||
@@ -88,6 +116,184 @@ function clearWorkflow(): SetWorkflowAction {
|
||||
payload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Local Backup Export Actions
|
||||
|
||||
export function startLocalBackupExport(): ThunkAction<
|
||||
void,
|
||||
StateType,
|
||||
unknown,
|
||||
SetWorkflowAction
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getBackups(getState());
|
||||
if (state.workflow != null) {
|
||||
log.error(
|
||||
`startLocalBackupExport: Cannot start, workflow is already ${state.workflow.type}/${state.workflow.workflow.step}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const localBackupFolder = itemStorage.get('localBackupFolder');
|
||||
if (!localBackupFolder) {
|
||||
log.error('startLocalBackupExport: Cannot start, no backup folder set');
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
dispatch({
|
||||
type: SET_WORKFLOW,
|
||||
payload: {
|
||||
type: 'local-backup',
|
||||
workflow: {
|
||||
step: LocalBackupExportSteps.ExportingMessages,
|
||||
abortController,
|
||||
localBackupFolder,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let complete = false;
|
||||
const onProgress = throttle(
|
||||
(currentBytes: number, totalBytes: number) => {
|
||||
if (complete) {
|
||||
return;
|
||||
}
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SET_WORKFLOW,
|
||||
payload: {
|
||||
type: 'local-backup',
|
||||
workflow: {
|
||||
step: LocalBackupExportSteps.ExportingAttachments,
|
||||
abortController,
|
||||
progress: {
|
||||
currentBytes,
|
||||
totalBytes,
|
||||
},
|
||||
localBackupFolder,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
200,
|
||||
{ leading: true, trailing: true }
|
||||
);
|
||||
|
||||
const { snapshotDir } = await backupsService.exportLocalBackup({
|
||||
backupsBaseDir: localBackupFolder,
|
||||
abortSignal: abortController.signal,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
complete = true;
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
dispatch(clearWorkflow());
|
||||
return;
|
||||
}
|
||||
|
||||
await itemStorage.put('lastLocalBackup', {
|
||||
timestamp: Date.now(),
|
||||
backupsFolder: localBackupFolder,
|
||||
snapshotDir,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: SET_WORKFLOW,
|
||||
payload: {
|
||||
type: 'local-backup',
|
||||
workflow: {
|
||||
step: LocalBackupExportSteps.Complete,
|
||||
localBackupFolder,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn('startLocalBackupExport:', toLogFormat(error));
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
dispatch(clearWorkflow());
|
||||
return;
|
||||
}
|
||||
|
||||
let errorDetails: LocalExportErrorDetails = {
|
||||
type: LocalExportErrors.General,
|
||||
};
|
||||
|
||||
if (error instanceof NotEnoughStorageError) {
|
||||
errorDetails = {
|
||||
type: LocalExportErrors.NotEnoughStorage,
|
||||
bytesNeeded: error.bytesNeeded,
|
||||
};
|
||||
}
|
||||
if (error instanceof RanOutOfStorageError) {
|
||||
errorDetails = {
|
||||
type: LocalExportErrors.RanOutOfStorage,
|
||||
bytesNeeded: error.bytesNeeded,
|
||||
};
|
||||
}
|
||||
if (error instanceof StoragePermissionsError) {
|
||||
errorDetails = {
|
||||
type: LocalExportErrors.StoragePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SET_WORKFLOW,
|
||||
payload: {
|
||||
type: 'local-backup',
|
||||
workflow: {
|
||||
step: LocalBackupExportSteps.Error,
|
||||
errorDetails,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cancelLocalBackupWorkflow(): ThunkAction<
|
||||
void,
|
||||
StateType,
|
||||
unknown,
|
||||
SetWorkflowAction
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getBackups(getState());
|
||||
const { workflow } = state;
|
||||
|
||||
if (workflow?.type !== 'local-backup') {
|
||||
log.error(
|
||||
`cancelLocalBackupWorkflow: Cannot cancel, workflow type is ${workflow?.type}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { step } = workflow.workflow;
|
||||
if (
|
||||
step !== LocalBackupExportSteps.ExportingMessages &&
|
||||
step !== LocalBackupExportSteps.ExportingAttachments
|
||||
) {
|
||||
log.error(
|
||||
`cancelLocalBackupWorkflow: Cannot cancel, previous state is ${step}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { abortController } = workflow.workflow;
|
||||
abortController.abort();
|
||||
|
||||
dispatch(clearWorkflow());
|
||||
};
|
||||
}
|
||||
|
||||
// Plaintext Export Actions
|
||||
|
||||
function startPlaintextExport(): ThunkAction<
|
||||
void,
|
||||
StateType,
|
||||
@@ -119,7 +325,7 @@ export function verifyWithOSForExport(
|
||||
includeMedia: boolean
|
||||
): ThunkAction<void, StateType, unknown, SetWorkflowAction> {
|
||||
return async (dispatch, getState) => {
|
||||
const previousWorkflow = getWorkflow(getState());
|
||||
const previousWorkflow = getPlaintextWorkflow(getState());
|
||||
if (
|
||||
!previousWorkflow ||
|
||||
previousWorkflow.step !== PlaintextExportSteps.ConfirmingExport
|
||||
@@ -171,7 +377,7 @@ function chooseExportLocation(): ThunkAction<
|
||||
SetWorkflowAction
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const previousWorkflow = getWorkflow(getState());
|
||||
const previousWorkflow = getPlaintextWorkflow(getState());
|
||||
if (
|
||||
!previousWorkflow ||
|
||||
previousWorkflow.step !== PlaintextExportSteps.ConfirmingWithOS
|
||||
@@ -207,7 +413,7 @@ function doPlaintextExport(
|
||||
exportPath: string
|
||||
): ThunkAction<void, StateType, unknown, SetWorkflowAction> {
|
||||
return async (dispatch, getState) => {
|
||||
const previousWorkflow = getWorkflow(getState());
|
||||
const previousWorkflow = getPlaintextWorkflow(getState());
|
||||
if (
|
||||
!previousWorkflow ||
|
||||
previousWorkflow.step !== PlaintextExportSteps.ChoosingLocation
|
||||
@@ -229,7 +435,6 @@ function doPlaintextExport(
|
||||
step: PlaintextExportSteps.ExportingMessages,
|
||||
abortController,
|
||||
exportPath,
|
||||
exportInBackground: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -257,7 +462,6 @@ function doPlaintextExport(
|
||||
totalBytes,
|
||||
},
|
||||
exportPath,
|
||||
exportInBackground: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -297,25 +501,25 @@ function doPlaintextExport(
|
||||
return;
|
||||
}
|
||||
|
||||
let errorDetails: PlaintextExportErrorDetails = {
|
||||
type: PlaintextExportErrors.General,
|
||||
let errorDetails: LocalExportErrorDetails = {
|
||||
type: LocalExportErrors.General,
|
||||
};
|
||||
|
||||
if (error instanceof NotEnoughStorageError) {
|
||||
errorDetails = {
|
||||
type: PlaintextExportErrors.NotEnoughStorage,
|
||||
type: LocalExportErrors.NotEnoughStorage,
|
||||
bytesNeeded: error.bytesNeeded,
|
||||
};
|
||||
}
|
||||
if (error instanceof RanOutOfStorageError) {
|
||||
errorDetails = {
|
||||
type: PlaintextExportErrors.RanOutOfStorage,
|
||||
type: LocalExportErrors.RanOutOfStorage,
|
||||
bytesNeeded: error.bytesNeeded,
|
||||
};
|
||||
}
|
||||
if (error instanceof StoragePermissionsError) {
|
||||
errorDetails = {
|
||||
type: PlaintextExportErrors.StoragePermissions,
|
||||
type: LocalExportErrors.StoragePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,7 +544,7 @@ function cancelWorkflow(): ThunkAction<
|
||||
SetWorkflowAction
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const previousWorkflow = getWorkflow(getState());
|
||||
const previousWorkflow = getPlaintextWorkflow(getState());
|
||||
if (
|
||||
!previousWorkflow ||
|
||||
(previousWorkflow.step !== PlaintextExportSteps.ExportingMessages &&
|
||||
@@ -385,19 +589,42 @@ export function reducer(
|
||||
if (action.type === SET_WORKFLOW) {
|
||||
const { payload } = action;
|
||||
|
||||
const existingType = state.workflow?.type;
|
||||
const existingStep = state.workflow?.workflow?.step;
|
||||
const newType = payload?.type;
|
||||
const newStep = payload?.workflow?.step;
|
||||
const existing = state.workflow;
|
||||
const next = payload;
|
||||
|
||||
// Prevent switching between different workflow types
|
||||
if (existing && next && existing.type !== next.type) {
|
||||
log.error(
|
||||
`backups/SET_WORKFLOW: Cannot switch from ${existing.type} to ${next.type}`
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Validate plaintext-export transitions
|
||||
if (
|
||||
existingStep &&
|
||||
newStep &&
|
||||
existingType === 'plaintext-export' &&
|
||||
newType === 'plaintext-export'
|
||||
isPlaintextExportWorkflow(existing) &&
|
||||
isPlaintextExportWorkflow(next)
|
||||
) {
|
||||
if (!validTransitions[existingStep].has(newStep)) {
|
||||
const existingStep = existing.workflow.step;
|
||||
const newStep = next.workflow.step;
|
||||
if (!plaintextExportValidTransitions[existingStep].has(newStep)) {
|
||||
log.error(
|
||||
`backups/SET_WORKFLOW: Invalid transition ${existingStep} to ${newStep}`
|
||||
`backups/SET_WORKFLOW: Invalid plaintext transition ${existingStep} to ${newStep}`
|
||||
);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate local-backup transitions
|
||||
if (
|
||||
isLocalBackupExportWorkflow(existing) &&
|
||||
isLocalBackupExportWorkflow(next)
|
||||
) {
|
||||
const existingStep = existing.workflow.step;
|
||||
const newStep = next.workflow.step;
|
||||
if (!localBackupExportValidTransitions[existingStep].has(newStep)) {
|
||||
log.error(
|
||||
`backups/SET_WORKFLOW: Invalid local-encrypted transition ${existingStep} to ${newStep}`
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { PlaintextExportSteps } from '../../types/Backups.std.js';
|
||||
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import type { BackupsStateType } from '../ducks/backups.preload.js';
|
||||
import type { PlaintextExportWorkflowType } from '../../types/Backups.std.js';
|
||||
import type {
|
||||
PlaintextExportWorkflowType,
|
||||
LocalBackupExportWorkflowType,
|
||||
} from '../../types/LocalExport.std.js';
|
||||
|
||||
export const getBackups = (state: StateType): BackupsStateType => state.backups;
|
||||
|
||||
@@ -21,21 +22,33 @@ export const shouldShowPlaintextWorkflow = createSelector(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(workflow.step === PlaintextExportSteps.ExportingAttachments ||
|
||||
workflow.step === PlaintextExportSteps.ExportingMessages) &&
|
||||
workflow.exportInBackground === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export const getWorkflow = createSelector(
|
||||
export const getPlaintextWorkflow = createSelector(
|
||||
getBackups,
|
||||
(backups: BackupsStateType): PlaintextExportWorkflowType | undefined => {
|
||||
return backups.workflow?.workflow;
|
||||
if (backups.workflow?.type !== 'plaintext-export') {
|
||||
return undefined;
|
||||
}
|
||||
return backups.workflow.workflow;
|
||||
}
|
||||
);
|
||||
|
||||
export const shouldShowLocalBackupWorkflow = createSelector(
|
||||
getBackups,
|
||||
(backups: BackupsStateType): boolean => {
|
||||
return backups.workflow?.type === 'local-backup';
|
||||
}
|
||||
);
|
||||
|
||||
export const getLocalBackupWorkflow = createSelector(
|
||||
getBackups,
|
||||
(backups: BackupsStateType): LocalBackupExportWorkflowType | undefined => {
|
||||
if (backups.workflow?.type !== 'local-backup') {
|
||||
return undefined;
|
||||
}
|
||||
return backups.workflow.workflow;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -34,7 +34,11 @@ import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.
|
||||
import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal.preload.js';
|
||||
import { DebugLogErrorModal } from '../../components/DebugLogErrorModal.dom.js';
|
||||
import { SmartPlaintextExportWorkflow } from './PlaintextExportWorkflow.preload.js';
|
||||
import { shouldShowPlaintextWorkflow } from '../selectors/backups.std.js';
|
||||
import { SmartLocalBackupExportWorkflow } from './LocalBackupExportWorkflow.preload.js';
|
||||
import {
|
||||
shouldShowPlaintextWorkflow,
|
||||
shouldShowLocalBackupWorkflow,
|
||||
} from '../selectors/backups.std.js';
|
||||
|
||||
function renderCallLinkAddNameModal(): React.JSX.Element {
|
||||
return <SmartCallLinkAddNameModal />;
|
||||
@@ -100,6 +104,10 @@ function renderPlaintextExportWorkflow(): React.JSX.Element {
|
||||
return <SmartPlaintextExportWorkflow />;
|
||||
}
|
||||
|
||||
function renderLocalBackupExportWorkflow(): React.JSX.Element {
|
||||
return <SmartLocalBackupExportWorkflow />;
|
||||
}
|
||||
|
||||
function renderStoriesSettings(): React.JSX.Element {
|
||||
return <SmartStoriesSettingsModal />;
|
||||
}
|
||||
@@ -124,6 +132,9 @@ export const SmartGlobalModalContainer = memo(
|
||||
const shouldShowPlaintextExportWorkflow = useSelector(
|
||||
shouldShowPlaintextWorkflow
|
||||
);
|
||||
const shouldShowLocalBackupExportWorkflow = useSelector(
|
||||
shouldShowLocalBackupWorkflow
|
||||
);
|
||||
|
||||
const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0;
|
||||
|
||||
@@ -300,6 +311,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
}
|
||||
renderNotePreviewModal={renderNotePreviewModal}
|
||||
renderPlaintextExportWorkflow={renderPlaintextExportWorkflow}
|
||||
renderLocalBackupExportWorkflow={renderLocalBackupExportWorkflow}
|
||||
renderProfileNameWarningModal={renderProfileNameWarningModal}
|
||||
renderUsernameOnboarding={renderUsernameOnboarding}
|
||||
renderSafetyNumber={renderSafetyNumber}
|
||||
@@ -310,6 +322,9 @@ export const SmartGlobalModalContainer = memo(
|
||||
safetyNumberChangedBlockingData={safetyNumberChangedBlockingData}
|
||||
safetyNumberModalContactId={safetyNumberModalContactId}
|
||||
shouldShowPlaintextExportWorkflow={shouldShowPlaintextExportWorkflow}
|
||||
shouldShowLocalBackupExportWorkflow={
|
||||
shouldShowLocalBackupExportWorkflow
|
||||
}
|
||||
stickerPackPreviewId={stickerPackPreviewId}
|
||||
tapToViewNotAvailableModalProps={tapToViewNotAvailableModalProps}
|
||||
theme={theme}
|
||||
|
||||
59
ts/state/smart/LocalBackupExportWorkflow.preload.tsx
Normal file
59
ts/state/smart/LocalBackupExportWorkflow.preload.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { createLogger } from '../../logging/log.std.js';
|
||||
import { getIntl, getUser } from '../selectors/user.std.js';
|
||||
import {
|
||||
getBackups,
|
||||
getLocalBackupWorkflow,
|
||||
shouldShowLocalBackupWorkflow,
|
||||
} from '../selectors/backups.std.js';
|
||||
import { useBackupActions } from '../ducks/backups.preload.js';
|
||||
import { LocalBackupExportWorkflow } from '../../components/LocalBackupExportWorkflow.dom.js';
|
||||
import { useToastActions } from '../ducks/toast.preload.js';
|
||||
|
||||
const log = createLogger('smart/LocalBackupExportWorkflow');
|
||||
|
||||
export const SmartLocalBackupExportWorkflow = memo(
|
||||
function SmartLocalBackupExportWorkflow() {
|
||||
const backups = useSelector(getBackups);
|
||||
const workflow = useSelector(getLocalBackupWorkflow);
|
||||
const shouldWeRender = useSelector(shouldShowLocalBackupWorkflow);
|
||||
const { osName } = useSelector(getUser);
|
||||
|
||||
const i18n = useSelector(getIntl);
|
||||
|
||||
const { openFileInFolder } = useToastActions();
|
||||
const { cancelLocalBackupWorkflow, clearWorkflow } = useBackupActions();
|
||||
|
||||
const containerType = backups.workflow?.type;
|
||||
if (containerType !== 'local-backup') {
|
||||
log.error(
|
||||
`SmartLocalBackupExportWorkflow: containerType is ${containerType}!`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!shouldWeRender) {
|
||||
log.error('SmartLocalBackupExportWorkflow: shouldWeRender=false!');
|
||||
return;
|
||||
}
|
||||
if (!workflow) {
|
||||
log.error('SmartLocalBackupExportWorkflow: no workflow!');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalBackupExportWorkflow
|
||||
cancelWorkflow={cancelLocalBackupWorkflow}
|
||||
clearWorkflow={clearWorkflow}
|
||||
i18n={i18n}
|
||||
openFileInFolder={openFileInFolder}
|
||||
osName={osName}
|
||||
workflow={workflow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -8,7 +8,7 @@ import { createLogger } from '../../logging/log.std.js';
|
||||
import { getIntl, getUser } from '../selectors/user.std.js';
|
||||
import {
|
||||
getBackups,
|
||||
getWorkflow,
|
||||
getPlaintextWorkflow,
|
||||
shouldShowPlaintextWorkflow,
|
||||
} from '../selectors/backups.std.js';
|
||||
import { useBackupActions } from '../ducks/backups.preload.js';
|
||||
@@ -20,7 +20,7 @@ const log = createLogger('smart/PlaintextExportWorkflow');
|
||||
export const SmartPlaintextExportWorkflow = memo(
|
||||
function SmartPlaintextExportWorkflow() {
|
||||
const backups = useSelector(getBackups);
|
||||
const workflow = useSelector(getWorkflow);
|
||||
const workflow = useSelector(getPlaintextWorkflow);
|
||||
const shouldWeRender = useSelector(shouldShowPlaintextWorkflow);
|
||||
const { osName } = useSelector(getUser);
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
const { changeLocation } = useNavActions();
|
||||
const { showToast } = useToastActions();
|
||||
const { internalAddDonationReceipt } = useDonationsActions();
|
||||
const { startPlaintextExport } = useBackupActions();
|
||||
const { startPlaintextExport, startLocalBackupExport } = useBackupActions();
|
||||
|
||||
// Selectors
|
||||
|
||||
@@ -289,8 +289,6 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
};
|
||||
|
||||
const validateBackup = () => backupsService._internalValidate();
|
||||
const exportLocalBackup = () =>
|
||||
backupsService._internalExportLocalEncryptedBackup();
|
||||
const pickLocalBackupFolder = () => backupsService.pickLocalBackupFolder();
|
||||
|
||||
const doDeleteAllData = () => renderClearingDataView();
|
||||
@@ -564,6 +562,7 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
backupSubscriptionStatus,
|
||||
backupTier,
|
||||
cloudBackupStatus,
|
||||
lastLocalBackup,
|
||||
localBackupFolder,
|
||||
backupMediaDownloadCompletedBytes,
|
||||
backupMediaDownloadTotalBytes,
|
||||
@@ -821,7 +820,6 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
defaultConversationColor={defaultConversationColor}
|
||||
deviceName={deviceName}
|
||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||
exportLocalBackup={exportLocalBackup}
|
||||
phoneNumber={phoneNumber}
|
||||
doDeleteAllData={doDeleteAllData}
|
||||
editCustomColor={editCustomColor}
|
||||
@@ -874,6 +872,7 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
isSyncSupported={isSyncSupported}
|
||||
isSystemTraySupported={isSystemTraySupported}
|
||||
isInternalUser={isInternalUser}
|
||||
lastLocalBackup={lastLocalBackup}
|
||||
lastSyncTime={lastSyncTime}
|
||||
localBackupFolder={localBackupFolder}
|
||||
localeOverride={localeOverride}
|
||||
@@ -960,6 +959,7 @@ export function SmartPreferences(): React.JSX.Element | null {
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
||||
showToast={showToast}
|
||||
startLocalBackupExport={startLocalBackupExport}
|
||||
startPlaintextExport={startPlaintextExport}
|
||||
theme={theme}
|
||||
themeSetting={themeSetting}
|
||||
|
||||
@@ -2,47 +2,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
export enum PlaintextExportSteps {
|
||||
ConfirmingExport = 'ConfirmingExport',
|
||||
ChoosingLocation = 'ChoosingLocation',
|
||||
ConfirmingWithOS = 'ConfirmingWithOS',
|
||||
ExportingMessages = 'ExportingMessages',
|
||||
ExportingAttachments = 'ExportingAttachments',
|
||||
Complete = 'Complete',
|
||||
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export type ExportProgress =
|
||||
| {
|
||||
totalBytes: number;
|
||||
currentBytes: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export enum PlaintextExportErrors {
|
||||
/**
|
||||
* Shared types/errors (plaintext & encrypted)
|
||||
*/
|
||||
export enum LocalExportErrors {
|
||||
General = 'General',
|
||||
NotEnoughStorage = 'NotEnoughStorage',
|
||||
RanOutOfStorage = 'RanOutOfStorage',
|
||||
StoragePermissions = 'StoragePermissions',
|
||||
}
|
||||
|
||||
export type PlaintextExportErrorDetails =
|
||||
| {
|
||||
type: PlaintextExportErrors.General;
|
||||
}
|
||||
| {
|
||||
type: PlaintextExportErrors.NotEnoughStorage;
|
||||
bytesNeeded: number;
|
||||
}
|
||||
| {
|
||||
type: PlaintextExportErrors.RanOutOfStorage;
|
||||
bytesNeeded: number;
|
||||
}
|
||||
| {
|
||||
type: PlaintextExportErrors.StoragePermissions;
|
||||
};
|
||||
|
||||
export class NotEnoughStorageError extends Error {
|
||||
constructor(public readonly bytesNeeded: number) {
|
||||
super('NotEnoughStorageError');
|
||||
@@ -59,6 +28,98 @@ export class StoragePermissionsError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalExportErrorDetails =
|
||||
| {
|
||||
type: LocalExportErrors.General;
|
||||
}
|
||||
| {
|
||||
type: LocalExportErrors.NotEnoughStorage;
|
||||
bytesNeeded: number;
|
||||
}
|
||||
| {
|
||||
type: LocalExportErrors.RanOutOfStorage;
|
||||
bytesNeeded: number;
|
||||
}
|
||||
| {
|
||||
type: LocalExportErrors.StoragePermissions;
|
||||
};
|
||||
|
||||
export type LocalExportProgress =
|
||||
| {
|
||||
totalBytes: number;
|
||||
currentBytes: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* LocalBackupExport types
|
||||
*/
|
||||
export enum LocalBackupExportSteps {
|
||||
ExportingMessages = 'ExportingMessages',
|
||||
ExportingAttachments = 'ExportingAttachments',
|
||||
Complete = 'Complete',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export type LocalBackupExportMetadata = {
|
||||
timestamp: number;
|
||||
backupsFolder: string;
|
||||
snapshotDir: string;
|
||||
};
|
||||
|
||||
export type LocalBackupExportWorkflowType =
|
||||
| {
|
||||
step: LocalBackupExportSteps.ExportingMessages;
|
||||
abortController: AbortController;
|
||||
localBackupFolder: string;
|
||||
}
|
||||
| {
|
||||
step: LocalBackupExportSteps.ExportingAttachments;
|
||||
abortController: AbortController;
|
||||
progress: LocalExportProgress;
|
||||
localBackupFolder: string;
|
||||
}
|
||||
| {
|
||||
step: LocalBackupExportSteps.Complete;
|
||||
localBackupFolder: string;
|
||||
}
|
||||
| {
|
||||
step: LocalBackupExportSteps.Error;
|
||||
errorDetails: LocalExportErrorDetails;
|
||||
};
|
||||
|
||||
export const localBackupExportValidTransitions: {
|
||||
[key in LocalBackupExportSteps]: Set<LocalBackupExportSteps>;
|
||||
} = {
|
||||
[LocalBackupExportSteps.ExportingMessages]: new Set([
|
||||
LocalBackupExportSteps.Complete,
|
||||
LocalBackupExportSteps.Error,
|
||||
LocalBackupExportSteps.ExportingAttachments,
|
||||
]),
|
||||
[LocalBackupExportSteps.ExportingAttachments]: new Set([
|
||||
// When updating progress, we transition to the same step with new progress
|
||||
LocalBackupExportSteps.ExportingAttachments,
|
||||
LocalBackupExportSteps.Complete,
|
||||
LocalBackupExportSteps.Error,
|
||||
]),
|
||||
// Terminal states
|
||||
[LocalBackupExportSteps.Complete]: new Set([]),
|
||||
[LocalBackupExportSteps.Error]: new Set([]),
|
||||
};
|
||||
|
||||
/**
|
||||
* PlaintextExport types
|
||||
*/
|
||||
export enum PlaintextExportSteps {
|
||||
ConfirmingExport = 'ConfirmingExport',
|
||||
ChoosingLocation = 'ChoosingLocation',
|
||||
ConfirmingWithOS = 'ConfirmingWithOS',
|
||||
ExportingMessages = 'ExportingMessages',
|
||||
ExportingAttachments = 'ExportingAttachments',
|
||||
Complete = 'Complete',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export type PlaintextExportWorkflowType =
|
||||
| {
|
||||
step: PlaintextExportSteps.ConfirmingExport;
|
||||
@@ -74,7 +135,6 @@ export type PlaintextExportWorkflowType =
|
||||
| {
|
||||
step: PlaintextExportSteps.ExportingMessages;
|
||||
abortController: AbortController;
|
||||
exportInBackground: boolean;
|
||||
exportPath: string;
|
||||
}
|
||||
| {
|
||||
@@ -82,8 +142,7 @@ export type PlaintextExportWorkflowType =
|
||||
// our onProgress callback is first called.
|
||||
step: PlaintextExportSteps.ExportingAttachments;
|
||||
abortController: AbortController;
|
||||
exportInBackground: boolean;
|
||||
progress: ExportProgress;
|
||||
progress: LocalExportProgress;
|
||||
exportPath: string;
|
||||
}
|
||||
| {
|
||||
@@ -93,11 +152,11 @@ export type PlaintextExportWorkflowType =
|
||||
| {
|
||||
// Not a normal step: Something went wrong, and we need to show error to the user
|
||||
step: PlaintextExportSteps.Error;
|
||||
errorDetails: PlaintextExportErrorDetails;
|
||||
errorDetails: LocalExportErrorDetails;
|
||||
};
|
||||
|
||||
// We can cancel in all states, but only need Canceling when we were actively exporting
|
||||
export const validTransitions: {
|
||||
export const plaintextExportValidTransitions: {
|
||||
[key in PlaintextExportSteps]: Set<PlaintextExportSteps>;
|
||||
} = {
|
||||
[PlaintextExportSteps.ConfirmingExport]: new Set([
|
||||
2
ts/types/Storage.d.ts
vendored
2
ts/types/Storage.d.ts
vendored
@@ -26,6 +26,7 @@ import type { RegisteredChallengeType } from '../challenge.dom.js';
|
||||
import type { ServerAlertsType } from '../util/handleServerAlerts.preload.js';
|
||||
import type { NotificationProfileOverride } from './NotificationProfile.std.js';
|
||||
import type { PhoneNumberSharingMode } from './PhoneNumberSharingMode.std.js';
|
||||
import type { LocalBackupExportMetadata } from './LocalExport.std.js';
|
||||
|
||||
export type AutoDownloadAttachmentType = {
|
||||
photos: boolean;
|
||||
@@ -239,6 +240,7 @@ export type StorageAccessType = {
|
||||
backupSubscriptionStatus: BackupsSubscriptionType | undefined;
|
||||
|
||||
backupKeyViewed: boolean;
|
||||
lastLocalBackup: LocalBackupExportMetadata;
|
||||
localBackupFolder: string | undefined;
|
||||
|
||||
// If true Desktop message history was restored from backup
|
||||
|
||||
Reference in New Issue
Block a user