Allow disabling local backups

This commit is contained in:
trevor-signal
2026-03-10 14:51:23 -04:00
committed by GitHub
parent 59d561a457
commit 0e9f93785e
11 changed files with 352 additions and 110 deletions

View File

@@ -8365,6 +8365,22 @@
"messageformat": "Change",
"description": "Button to change the folder in which local on-device backups are stored."
},
"icu:Preferences__local-backups-turn-off": {
"messageformat": "Turn off backups",
"description": "Label for settings row to disable local on-device backups."
},
"icu:Preferences__local-backups-turn-off-action": {
"messageformat": "Turn off",
"description": "Button text in local backups settings and confirmation modal to disable local backups."
},
"icu:Preferences__local-backups-turn-off-confirmation": {
"messageformat": "You will no longer be able to create backups on this computer without re-enabling.",
"description": "Confirmation modal body for disabling local on-device backups."
},
"icu:Preferences__local-backups-turn-off-delete": {
"messageformat": "Delete my backup from this computer",
"description": "Checkbox label in confirmation modal for disabling local backups and deleting existing backup files."
},
"icu:Preferences__local-backups-copy-key": {
"messageformat": "Copy to clipboard",
"description": "Button label for copying the backup key to clipboard in the settings for local on-device backups"

View File

@@ -824,7 +824,7 @@ $secondary-text-color: light-dark(
}
}
.Preferences--BackupsRow .Preferences__control {
.Preferences--BackupsRow .Preferences__flow-control {
padding-block: 10px;
align-items: initial;
}

View File

@@ -214,12 +214,20 @@ export namespace AxoAlertDialog {
export type CancelProps = Readonly<{
children: ReactNode;
disabled?: boolean;
focusableWhenDisabled?: boolean;
}>;
export const Cancel: FC<CancelProps> = memo(props => {
return (
<AlertDialog.Cancel asChild>
<AxoButton.Root variant="secondary" size="md" width="grow">
<AxoButton.Root
variant="secondary"
size="md"
width="grow"
disabled={props.disabled}
focusableWhenDisabled={props.focusableWhenDisabled}
>
{props.children}
</AxoButton.Root>
</AlertDialog.Cancel>

View File

@@ -386,6 +386,8 @@ export namespace AxoDialog {
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
experimentalSpinner?: { 'aria-label': string } | null;
disabled?: boolean;
focusableWhenDisabled?: boolean;
onClick: () => void;
children: ReactNode;
}>;
@@ -397,6 +399,8 @@ export namespace AxoDialog {
symbol={props.symbol}
arrow={props.arrow}
experimentalSpinner={props.experimentalSpinner}
disabled={props.disabled}
focusableWhenDisabled={props.focusableWhenDisabled}
size="md"
width="grow"
onClick={props.onClick}

View File

@@ -598,6 +598,7 @@ export default {
openFileInFolder: action('openFileInFolder'),
pickLocalBackupFolder: () =>
Promise.resolve('/home/signaluser/Signal Backups/'),
disableLocalBackups: () => Promise.resolve(),
promptOSAuth: () => Promise.resolve('success'),
refreshCloudBackupStatus: action('refreshCloudBackupStatus'),
refreshBackupSubscriptionStatus: action('refreshBackupSubscriptionStatus'),

View File

@@ -245,6 +245,11 @@ type PropsFunctionType = {
// Other props
addCustomColor: (color: CustomColorType) => unknown;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
doDeleteAllData: () => unknown;
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
getMessageCountBySchemaVersion: () => Promise<MessageCountBySchemaVersionType>;
@@ -403,6 +408,7 @@ export function Preferences({
customColors,
defaultConversationColor,
deviceName = '',
disableLocalBackups,
doDeleteAllData,
editCustomColor,
emojiSkinToneDefault,
@@ -2283,6 +2289,7 @@ export function Preferences({
openFileInFolder={openFileInFolder}
osName={osName}
pickLocalBackupFolder={pickLocalBackupFolder}
disableLocalBackups={disableLocalBackups}
settingsLocation={settingsLocation}
promptOSAuth={promptOSAuth}
refreshCloudBackupStatus={refreshCloudBackupStatus}

View File

@@ -16,7 +16,6 @@ import {
LightIconLabel,
SettingsRow,
} from './PreferencesUtil.dom.js';
import { ButtonVariant } from './Button.dom.js';
import type { SettingsLocation } from '../types/Nav.std.js';
import { SettingsPage } from '../types/Nav.std.js';
import { I18n } from './I18n.dom.js';
@@ -26,7 +25,6 @@ import type {
PromptOSAuthReasonType,
PromptOSAuthResultType,
} from '../util/os/promptOSAuthMain.main.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
import { BackupLevel } from '../services/backups/types.std.js';
import {
@@ -65,6 +63,7 @@ export function PreferencesBackups({
openFileInFolder,
osName,
pickLocalBackupFolder,
disableLocalBackups,
backupMediaDownloadStatus,
cancelBackupMediaDownload,
pauseBackupMediaDownload,
@@ -94,6 +93,11 @@ export function PreferencesBackups({
settingsLocation: SettingsLocation;
backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined;
cancelBackupMediaDownload: () => void;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
pauseBackupMediaDownload: () => void;
resumeBackupMediaDownload: () => void;
pickLocalBackupFolder: () => Promise<string | undefined>;
@@ -106,8 +110,6 @@ export function PreferencesBackups({
showToast: ShowToastAction;
startLocalBackupExport: () => void;
}): React.JSX.Element | null {
const [authError, setAuthError] =
useState<Omit<PromptOSAuthResultType, 'success'>>();
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
useEffect(() => {
@@ -162,6 +164,7 @@ export function PreferencesBackups({
osName={osName}
settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder}
disableLocalBackups={disableLocalBackups}
promptOSAuth={promptOSAuth}
setSettingsLocation={setSettingsLocation}
showToast={showToast}
@@ -253,72 +256,54 @@ export function PreferencesBackups({
function renderLocalBackups() {
return (
<>
<SettingsRow
className="Preferences--BackupsRow"
title={i18n('icu:Preferences__backup-other-ways')}
>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<LightIconLabel icon="Preferences__LocalBackupsIcon">
<label>
{i18n('icu:Preferences__local-backups')}{' '}
<div className="Preferences__description">
{i18n('icu:Preferences--local-backups-off-description')}
</div>
</label>
</LightIconLabel>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="secondary"
size="lg"
disabled={isAuthPending}
onClick={async () => {
setAuthError(undefined);
if (!isLocalBackupsSetup) {
try {
setIsAuthPending(true);
const result = await promptOSAuth('enable-backups');
if (result !== 'success' && result !== 'unsupported') {
setAuthError(result);
return;
}
} finally {
setIsAuthPending(false);
}
}
setSettingsLocation({ page: SettingsPage.LocalBackups });
}}
>
{isLocalBackupsSetup
? i18n('icu:Preferences__button--manage')
: i18n('icu:Preferences__button--set-up')}
</AxoButton.Root>
</div>
</FlowingControl>
</SettingsRow>
{authError && (
<ConfirmationDialog
i18n={i18n}
dialogName="PreferencesLocalBackups--ErrorDialog"
onClose={() => setAuthError(undefined)}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')}
<SettingsRow
className="Preferences--BackupsRow"
title={i18n('icu:Preferences__backup-other-ways')}
>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<LightIconLabel icon="Preferences__LocalBackupsIcon">
<label>
{i18n('icu:Preferences__local-backups')}{' '}
<div className="Preferences__description">
{i18n('icu:Preferences--local-backups-off-description')}
</div>
</label>
</LightIconLabel>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
{getOSAuthErrorString(authError) ?? i18n('icu:error')}
</ConfirmationDialog>
)}
</>
<AxoButton.Root
variant="secondary"
size="lg"
disabled={isAuthPending}
onClick={async () => {
if (!isLocalBackupsSetup) {
try {
setIsAuthPending(true);
const result = await promptOSAuth('enable-backups');
if (result !== 'success' && result !== 'unsupported') {
return;
}
} finally {
setIsAuthPending(false);
}
}
setSettingsLocation({ page: SettingsPage.LocalBackups });
}}
>
{isLocalBackupsSetup
? i18n('icu:Preferences__button--manage')
: i18n('icu:Preferences__button--set-up')}
</AxoButton.Root>
</div>
</FlowingControl>
</SettingsRow>
);
}
@@ -380,22 +365,3 @@ export function renderFreeBackupsSummary({
</div>
);
}
export function getOSAuthErrorString(
authError: Omit<PromptOSAuthResultType, 'success'> | undefined
): string | undefined {
if (!authError) {
return undefined;
}
// TODO: DESKTOP-8895
if (authError === 'unauthorized') {
return 'This action could not be completed because system authentication failed. Please try again or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
if (authError === 'unauthorized-no-windows-ucv') {
return 'This action could not be completed because Windows Hello is not enabled on your computer. Please set up Windows Hello and try again, or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
return 'The action could not be completed because authentication is not available on this computer. Please open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}

View File

@@ -17,11 +17,7 @@ import {
FlowingSettingsControl as FlowingControl,
SettingsRow,
} from './PreferencesUtil.dom.js';
import { ButtonVariant } from './Button.dom.js';
import {
getOSAuthErrorString,
SIGNAL_BACKUPS_LEARN_MORE_URL,
} from './PreferencesBackups.dom.js';
import { SIGNAL_BACKUPS_LEARN_MORE_URL } from './PreferencesBackups.dom.js';
import { I18n } from './I18n.dom.js';
import type { SettingsLocation } from '../types/Nav.std.js';
import { SettingsPage } from '../types/Nav.std.js';
@@ -33,17 +29,24 @@ import type {
PromptOSAuthReasonType,
PromptOSAuthResultType,
} from '../util/os/promptOSAuthMain.main.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
import { AxoDialog } from '../axo/AxoDialog.dom.js';
import { AxoCheckbox } from '../axo/AxoCheckbox.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';
import { tw } from '../axo/tw.dom.js';
import { createLogger } from '../logging/log.std.js';
import { toLogFormat } from '../types/errors.std.js';
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
const { noop } = lodash;
const log = createLogger('PreferencesLocalBackups');
export function PreferencesLocalBackups({
accountEntropyPool,
backupKeyViewed,
disableLocalBackups,
i18n,
lastLocalBackup,
localBackupFolder,
@@ -59,6 +62,11 @@ export function PreferencesLocalBackups({
}: {
accountEntropyPool: string | undefined;
backupKeyViewed: boolean;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
i18n: LocalizerType;
lastLocalBackup: LocalBackupExportMetadata | undefined;
localBackupFolder: string | undefined;
@@ -77,6 +85,7 @@ export function PreferencesLocalBackups({
const [authError, setAuthError] =
React.useState<Omit<PromptOSAuthResultType, 'success'>>();
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
const [isDisablePending, setIsDisablePending] = useState<boolean>(false);
if (!localBackupFolder) {
return (
@@ -237,6 +246,28 @@ export function PreferencesLocalBackups({
</AxoButton.Root>
</div>
</FlowingControl>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<label>{i18n('icu:Preferences__local-backups-turn-off')}</label>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="subtle-destructive"
size="lg"
onClick={() => {
setIsDisablePending(true);
}}
>
{i18n('icu:Preferences__local-backups-turn-off-action')}
</AxoButton.Root>
</div>
</FlowingControl>
</SettingsRow>
<SettingsRow className="Preferences--BackupsRow">
<div className="Preferences__padding">
@@ -252,21 +283,169 @@ export function PreferencesLocalBackups({
</div>
</SettingsRow>
{authError && (
<ConfirmationDialog
{isDisablePending ? (
<DisableLocalBackupsDialog
i18n={i18n}
dialogName="PreferencesLocalBackups--ErrorDialog"
onClose={() => setAuthError(undefined)}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')}
disableLocalBackups={disableLocalBackups}
onCancel={() => setIsDisablePending(false)}
onComplete={() => {
setIsDisablePending(false);
setSettingsLocation({ page: SettingsPage.Backups });
}}
onError={() => {
showToast({
toastType: ToastType.Error,
});
setIsDisablePending(false);
setSettingsLocation({ page: SettingsPage.Backups });
}}
/>
) : null}
{authError ? (
<AxoAlertDialog.Root
open
onOpenChange={open => {
if (!open) {
setAuthError(undefined);
}
}}
>
{getOSAuthErrorString(authError) ?? i18n('icu:error')}
</ConfirmationDialog>
)}
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Description>
{getOSAuthErrorString(authError) ?? i18n('icu:error')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>{i18n('icu:ok')}</AxoAlertDialog.Cancel>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
) : null}
</>
);
}
function DisableLocalBackupsDialog({
i18n,
disableLocalBackups,
onComplete,
onCancel,
onError,
}: {
i18n: LocalizerType;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
onComplete: () => void;
onCancel: () => void;
onError: (e: unknown) => void;
}) {
const [isPending, setIsPending] = useState<boolean>(false);
const [deleteExistingBackups, setDeleteExistingBackups] =
useState<boolean>(true);
const handleDisableLocalBackups = useCallback(async () => {
if (isPending) {
return;
}
try {
setIsPending(true);
await disableLocalBackups({ deleteExistingBackups });
onComplete();
} catch (e) {
log.error(
'Error when disabling local backups',
{ deleteExistingBackups },
toLogFormat(e)
);
onError(e);
} finally {
setIsPending(false);
}
}, [
isPending,
deleteExistingBackups,
onComplete,
onError,
disableLocalBackups,
]);
return (
<AxoDialog.Root
open
onOpenChange={(open: boolean) => {
if (isPending) {
return;
}
if (!open) {
onCancel();
}
}}
>
<AxoDialog.Content
size="md"
escape={isPending ? 'cancel-is-destructive' : 'cancel-is-noop'}
>
<div className={tw('p-2')}>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:Preferences__local-backups-turn-off')}
</AxoDialog.Title>
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<AxoDialog.Description>
<div className={tw('mb-2 text-label-secondary')}>
{i18n('icu:Preferences__local-backups-turn-off-confirmation')}
</div>
</AxoDialog.Description>
<label
className={tw('flex items-center gap-3 px-4 py-2.5')}
htmlFor="deleteLocalBackupsCheckbox"
>
<AxoCheckbox.Root
id="deleteLocalBackupsCheckbox"
variant="square"
checked={deleteExistingBackups}
disabled={isPending}
onCheckedChange={setDeleteExistingBackups}
/>
{i18n('icu:Preferences__local-backups-turn-off-delete')}
</label>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action
variant="secondary"
onClick={onCancel}
disabled={isPending}
>
{i18n('icu:cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="destructive"
experimentalSpinner={
isPending ? { 'aria-label': i18n('icu:loading') } : null
}
onClick={handleDisableLocalBackups}
>
{i18n('icu:Preferences__local-backups-turn-off-action')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</div>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function LocalBackupsSetupFolderPicker({
i18n,
pickLocalBackupFolder,
@@ -525,3 +704,22 @@ function LocalBackupsBackupKeyTextarea({
/>
);
}
function getOSAuthErrorString(
authError: Omit<PromptOSAuthResultType, 'success'> | undefined
): string | undefined {
if (!authError) {
return undefined;
}
// TODO: DESKTOP-8895
if (authError === 'unauthorized') {
return 'This action could not be completed because system authentication failed. Please try again or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
if (authError === 'unauthorized-no-windows-ucv') {
return 'This action could not be completed because Windows Hello is not enabled on your computer. Please set up Windows Hello and try again, or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
return 'The action could not be completed because authentication is not available on this computer. Please open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}

View File

@@ -6,8 +6,8 @@ import { PassThrough } from 'node:stream';
import type { Readable, Writable } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rm, stat, unlink, writeFile } from 'node:fs/promises';
import fsExtra, { exists } from 'fs-extra';
import { join } from 'node:path';
import fsExtra from 'fs-extra';
import { basename, join } from 'node:path';
import { createGzip, createGunzip } from 'node:zlib';
import { createCipheriv, createHmac, randomBytes } from 'node:crypto';
import lodash from 'lodash';
@@ -94,6 +94,7 @@ import {
validateLocalBackupStructure,
getLocalBackupFilesDirectory,
getLocalBackupSnapshotDirectory,
LOCAL_BACKUP_DIR_NAME,
} from './util/localBackup.node.js';
import {
AttachmentPermanentlyMissingError,
@@ -118,7 +119,7 @@ import {
import { getFreeDiskSpace } from '../../util/getFreeDiskSpace.node.js';
import { isFeaturedEnabledNoRedux } from '../../util/isFeatureEnabled.dom.js';
const { ensureFile } = fsExtra;
const { ensureFile, exists } = fsExtra;
const { throttle } = lodashFp;
@@ -1443,13 +1444,44 @@ export class BackupsService {
return;
}
const localBackupsBaseDir = join(backupsParentDir, 'SignalBackups');
const localBackupsBaseDir = join(backupsParentDir, LOCAL_BACKUP_DIR_NAME);
await mkdir(localBackupsBaseDir, { recursive: true });
await itemStorage.put('localBackupFolder', localBackupsBaseDir);
return localBackupsBaseDir;
}
async disableLocalBackups({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}): Promise<void> {
const backupsBaseDir = itemStorage.get('localBackupFolder');
await Promise.all([
itemStorage.remove('lastLocalBackup'),
itemStorage.remove('localBackupFolder'),
itemStorage.remove('backupKeyViewed'),
]);
if (deleteExistingBackups) {
if (!backupsBaseDir) {
log.error('disableLocalBackups: backups dir not set');
return;
}
if (basename(backupsBaseDir) !== LOCAL_BACKUP_DIR_NAME) {
log.warn(
'disableLocalBackups: backups dir does not have expected name, bailing on deleting backups'
);
return;
}
await rm(backupsBaseDir, { force: true, recursive: true });
log.info('disableLocalBackups: deleted backups directory');
}
}
}
export const backupsService = new BackupsService();

View File

@@ -28,6 +28,8 @@ const LOCAL_BACKUP_SNAPSHOT_DIR_PREFIX = 'signal-backup-';
const LOCAL_BACKUP_SNAPSHOT_DIR_PATTERN =
/^signal-backup-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/;
export const LOCAL_BACKUP_DIR_NAME = 'SignalBackups';
export function getLocalBackupSnapshotDirectory(
backupsBaseDir: string,
timestamp: number
@@ -55,7 +57,14 @@ export async function getAllPathsInLocalBackupFilesDirectory({
const allEntries = await readdir(filesDir, {
withFileTypes: true,
recursive: true,
}).catch(error => {
// Be resilient to files folder not exist
if ('code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
});
return allEntries
.filter(entry => entry.isFile())
.map(entry => join(entry.parentPath, entry.name));
@@ -105,7 +114,7 @@ export async function pruneLocalBackups({
if (snapshotDirsToDelete.length === 1) {
fnLog.info('pruning one snapshot');
} else {
fnLog.error(`pruning ${snapshotDirsToDelete.length} snapshots`);
fnLog.warn(`pruning ${snapshotDirsToDelete.length} snapshots`);
}
await Promise.all(

View File

@@ -829,6 +829,7 @@ export function SmartPreferences(): React.JSX.Element | null {
customColors={customColors}
defaultConversationColor={defaultConversationColor}
deviceName={deviceName}
disableLocalBackups={backupsService.disableLocalBackups}
emojiSkinToneDefault={emojiSkinToneDefault}
phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData}