Update local backup export UI

This commit is contained in:
trevor-signal
2026-01-16 15:36:06 -05:00
committed by GitHub
parent 094f41fcbc
commit 3f98b4cc8f
23 changed files with 1085 additions and 280 deletions

View File

@@ -7480,6 +7480,30 @@
"messageformat": "Your chat history cant be exported because Signal doesnt 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."

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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,

View File

@@ -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);

View 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,
},
}}
/>
);
}

View 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>
);
}

View File

@@ -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=""

View File

@@ -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,
},
}}
/>

View File

@@ -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 {

View File

@@ -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/',
};

View File

@@ -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}

View File

@@ -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}
/>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
{

View File

@@ -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;
}

View File

@@ -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;
}
);

View File

@@ -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}

View 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}
/>
);
}
);

View File

@@ -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);

View File

@@ -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}

View File

@@ -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([

View File

@@ -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