diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 15c3b8e16b..621243c745 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index f7789b623a..79d5766880 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -824,7 +824,7 @@ $secondary-text-color: light-dark( } } -.Preferences--BackupsRow .Preferences__control { +.Preferences--BackupsRow .Preferences__flow-control { padding-block: 10px; align-items: initial; } diff --git a/ts/axo/AxoAlertDialog.dom.tsx b/ts/axo/AxoAlertDialog.dom.tsx index c99e2fcacb..55bcd602b0 100644 --- a/ts/axo/AxoAlertDialog.dom.tsx +++ b/ts/axo/AxoAlertDialog.dom.tsx @@ -214,12 +214,20 @@ export namespace AxoAlertDialog { export type CancelProps = Readonly<{ children: ReactNode; + disabled?: boolean; + focusableWhenDisabled?: boolean; }>; export const Cancel: FC = memo(props => { return ( - + {props.children} diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index dd58e9a31a..ec8c6d846a 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -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} diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index e2634762b1..18de3c9b6e 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -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'), diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 8841c9e504..f5d2eafaec 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -245,6 +245,11 @@ type PropsFunctionType = { // Other props addCustomColor: (color: CustomColorType) => unknown; + disableLocalBackups: ({ + deleteExistingBackups, + }: { + deleteExistingBackups: boolean; + }) => Promise; doDeleteAllData: () => unknown; editCustomColor: (colorId: string, color: CustomColorType) => unknown; getMessageCountBySchemaVersion: () => Promise; @@ -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} diff --git a/ts/components/PreferencesBackups.dom.tsx b/ts/components/PreferencesBackups.dom.tsx index 7631af3ceb..53b6a68db0 100644 --- a/ts/components/PreferencesBackups.dom.tsx +++ b/ts/components/PreferencesBackups.dom.tsx @@ -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; pauseBackupMediaDownload: () => void; resumeBackupMediaDownload: () => void; pickLocalBackupFolder: () => Promise; @@ -106,8 +110,6 @@ export function PreferencesBackups({ showToast: ShowToastAction; startLocalBackupExport: () => void; }): React.JSX.Element | null { - const [authError, setAuthError] = - useState>(); const [isAuthPending, setIsAuthPending] = useState(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 ( - <> - - -
- - - -
-
- { - 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')} - -
-
-
- - {authError && ( - setAuthError(undefined)} - cancelButtonVariant={ButtonVariant.Secondary} - cancelText={i18n('icu:ok')} + + +
+ + + +
+
- {getOSAuthErrorString(authError) ?? i18n('icu:error')} - - )} - + { + 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')} + +
+
+
); } @@ -380,22 +365,3 @@ export function renderFreeBackupsSummary({ ); } - -export function getOSAuthErrorString( - authError: Omit | 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.'; -} diff --git a/ts/components/PreferencesLocalBackups.dom.tsx b/ts/components/PreferencesLocalBackups.dom.tsx index eaa44f58b7..6e590dc707 100644 --- a/ts/components/PreferencesLocalBackups.dom.tsx +++ b/ts/components/PreferencesLocalBackups.dom.tsx @@ -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; i18n: LocalizerType; lastLocalBackup: LocalBackupExportMetadata | undefined; localBackupFolder: string | undefined; @@ -77,6 +85,7 @@ export function PreferencesLocalBackups({ const [authError, setAuthError] = React.useState>(); const [isAuthPending, setIsAuthPending] = useState(false); + const [isDisablePending, setIsDisablePending] = useState(false); if (!localBackupFolder) { return ( @@ -237,6 +246,28 @@ export function PreferencesLocalBackups({ + +
+ +
+
+ { + setIsDisablePending(true); + }} + > + {i18n('icu:Preferences__local-backups-turn-off-action')} + +
+
@@ -252,21 +283,169 @@ export function PreferencesLocalBackups({
- {authError && ( - 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 ? ( + { + if (!open) { + setAuthError(undefined); + } + }} > - {getOSAuthErrorString(authError) ?? i18n('icu:error')} - - )} + + + + {getOSAuthErrorString(authError) ?? i18n('icu:error')} + + + + {i18n('icu:ok')} + + + + ) : null} ); } +function DisableLocalBackupsDialog({ + i18n, + disableLocalBackups, + onComplete, + onCancel, + onError, +}: { + i18n: LocalizerType; + disableLocalBackups: ({ + deleteExistingBackups, + }: { + deleteExistingBackups: boolean; + }) => Promise; + onComplete: () => void; + onCancel: () => void; + onError: (e: unknown) => void; +}) { + const [isPending, setIsPending] = useState(false); + + const [deleteExistingBackups, setDeleteExistingBackups] = + useState(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 ( + { + if (isPending) { + return; + } + + if (!open) { + onCancel(); + } + }} + > + +
+ + + {i18n('icu:Preferences__local-backups-turn-off')} + + + + +
+ {i18n('icu:Preferences__local-backups-turn-off-confirmation')} +
+
+ + +
+ + + + {i18n('icu:cancel')} + + + {i18n('icu:Preferences__local-backups-turn-off-action')} + + + +
+
+
+ ); +} + function LocalBackupsSetupFolderPicker({ i18n, pickLocalBackupFolder, @@ -525,3 +704,22 @@ function LocalBackupsBackupKeyTextarea({ /> ); } + +function getOSAuthErrorString( + authError: Omit | 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.'; +} diff --git a/ts/services/backups/index.preload.ts b/ts/services/backups/index.preload.ts index 1e5d5ed83f..e28b505c53 100644 --- a/ts/services/backups/index.preload.ts +++ b/ts/services/backups/index.preload.ts @@ -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 { + 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(); diff --git a/ts/services/backups/util/localBackup.node.ts b/ts/services/backups/util/localBackup.node.ts index 6d4b43cb94..2f88ec0a77 100644 --- a/ts/services/backups/util/localBackup.node.ts +++ b/ts/services/backups/util/localBackup.node.ts @@ -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( diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index b862e86e30..218cef7775 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -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}