From c4378d9c248f684a6c18c764fcf89995e9ff0e3d Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 19 Nov 2025 02:12:04 +1000 Subject: [PATCH] Support for exporting chats to disk --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + ACKNOWLEDGMENTS.md | 2 +- _locales/en/messages.json | 144 ++++++ app/main.main.ts | 1 + .../org.signalapp.enable-backups.policy | 6 +- .../org.signalapp.plaintext-export.policy | 16 + .../org.signalapp.view-aep.policy | 6 +- package.json | 7 +- patches/app-builder-lib.patch | 9 +- pnpm-lock.yaml | 20 +- protos/Backups.proto | 51 ++- ts/CI.preload.ts | 9 +- ts/RemoteConfig.dom.ts | 32 +- ts/axo/AxoAlertDialog.dom.tsx | 2 +- ts/axo/AxoDialog.dom.tsx | 4 +- ts/components/GlobalModalContainer.dom.tsx | 11 + .../PlaintextExportWorkflow.dom.stories.tsx | 175 ++++++++ ts/components/PlaintextExportWorkflow.dom.tsx | 306 +++++++++++++ ts/components/Preferences.dom.stories.tsx | 12 +- ts/components/Preferences.dom.tsx | 32 ++ .../PreferencesNotificationProfiles.dom.tsx | 7 +- .../AttachmentLocalBackupManager.preload.ts | 306 +++++-------- ts/scripts/gen-policy-files.node.ts | 110 +++++ ts/services/backups/export.preload.ts | 179 ++++++-- ts/services/backups/import.preload.ts | 45 ++ ts/services/backups/index.preload.ts | 281 ++++++++++-- ts/services/backups/types.std.ts | 26 +- .../backups/util/filePointers.preload.ts | 17 +- ts/services/backups/validator.preload.ts | 5 +- .../createExpiringEntityCleanupService.std.ts | 2 +- ts/state/actions.preload.ts | 2 + ts/state/ducks/backups.preload.ts | 413 ++++++++++++++++++ ts/state/getInitialState.preload.ts | 2 + ts/state/initializeRedux.preload.ts | 1 + ts/state/reducer.preload.ts | 2 + ts/state/selectors/backups.std.ts | 41 ++ .../smart/GlobalModalContainer.preload.tsx | 11 + .../smart/PlaintextExportWorkflow.preload.tsx | 61 +++ ts/state/smart/Preferences.preload.tsx | 42 +- ts/state/types.std.ts | 2 + .../backup/filePointer_test.preload.ts | 5 + .../AttachmentBackupManager_test.preload.ts | 6 +- .../util/isFeatureEnabled_test.dom.ts | 187 ++++++++ ts/types/AttachmentBackup.std.ts | 14 +- ts/types/Backups.std.ts | 126 ++++++ ts/types/RendererConfig.std.ts | 1 + ts/types/Storage.d.ts | 11 + ts/util/getFreeDiskSpace.node.ts | 9 + ts/util/isFeatureEnabled.dom.ts | 98 +++++ ts/util/os/promptOSAuthMain.main.ts | 9 +- ts/util/promptOSAuth.preload.ts | 34 +- ts/util/timestamp.std.ts | 12 + 53 files changed, 2549 insertions(+), 366 deletions(-) rename build/{ => policy-templates}/org.signalapp.enable-backups.policy (79%) create mode 100644 build/policy-templates/org.signalapp.plaintext-export.policy rename build/{ => policy-templates}/org.signalapp.view-aep.policy (78%) create mode 100644 ts/components/PlaintextExportWorkflow.dom.stories.tsx create mode 100644 ts/components/PlaintextExportWorkflow.dom.tsx create mode 100644 ts/scripts/gen-policy-files.node.ts create mode 100644 ts/state/ducks/backups.preload.ts create mode 100644 ts/state/selectors/backups.std.ts create mode 100644 ts/state/smart/PlaintextExportWorkflow.preload.tsx create mode 100644 ts/test-electron/util/isFeatureEnabled_test.dom.ts create mode 100644 ts/types/Backups.std.ts create mode 100644 ts/util/getFreeDiskSpace.node.ts create mode 100644 ts/util/isFeatureEnabled.dom.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 503907c03d..59000b546e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: repository: 'signalapp/Signal-Message-Backup-Tests' - ref: '455fbe5854bd3be5002f17ae929a898c0975adc4' + ref: '551f9ad1186d196e8698df4a5750b239f0796a70' path: 'backup-integration-tests' - run: xvfb-run --auto-servernum pnpm run test-electron diff --git a/.gitignore b/.gitignore index 02aaef518f..e8645b1149 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules_bkp coverage/* build/curve25519_compiled.js build/compact-locales +build/*.policy stylesheets/*.css.map /dist .DS_Store diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index bdafc7707c..735ec23d15 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -9836,7 +9836,7 @@ DEALINGS IN THE SOFTWARE. ``` -## gimli 0.31.1, heck 0.5.0, unicode-xid 0.2.6 +## gimli 0.31.1, heck 0.5.0, unicode-segmentation 1.12.0, unicode-xid 0.2.6 ``` Copyright (c) 2015 The Rust Project Developers diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 352b602380..df26f2f0ae 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7200,6 +7200,118 @@ "messageformat": "Notification content", "description": "Label for the notification content setting select box" }, + "icu:PlaintextExport--PreferencesRow--Header": { + "messageformat": "Export chat history", + "description": "Shown in the Preferences/Chats page, header for a row" + }, + "icu:PlaintextExport--PreferencesRow--Description": { + "messageformat": "Export a machine-readable JSON copy of all your chats", + "description": "Shown in the Preferences/Chats page, detail text for a row" + }, + "icu:PlaintextExport--ActionButton": { + "messageformat": "Export", + "description": "Shown on a button to take the action of exporting chat history" + }, + "icu:PlaintextExport--Confirmation--Header": { + "messageformat": "Export chat history?", + "description": "Title of confirmation dialog shown before the export starts" + }, + "icu:PlaintextExport--Confirmation--Description": { + "messageformat": "BE CAREFUL! Do NOT share this file with anyone. Your chat history will be saved to your computer and other apps can access it depending on your computer’s permissions.", + "description": "Description of confirmation dialog shown before the export starts" + }, + "icu:PlaintextExport--Confirmation--IncludeMedia": { + "messageformat": "Include media (larger file size)", + "description": "Description for a checkbox shown on the confirmation dialog, to choose whether to include media in the export" + }, + "icu:PlaintextExport--Confirmation--ContinueButton": { + "messageformat": "Continue", + "description": "Button on confirmation dialog to continue on through the export process" + }, + "icu:PlaintextExport--Confirmation--WaitingLabel": { + "messageformat": "Waiting for input", + "description": "Label for spinner on Confirmation dialog when user as pressed Continue, and OS Auth/Export Location dialogs are show" + }, + "icu:PlaintextExport--OSPrompt--Mac": { + "messageformat": "export your chat history", + "description": "Shown as part of an OS prompt with a final string like 'Signal Desktop is trying to export your chat history'" + }, + "icu:PlaintextExport--OSPrompt--Windows": { + "messageformat": "Verify your identity to export your chat history.", + "description": "Shown as the body text of an OS prompt with a title like \"Making sure it's you\"" + }, + "icu:PlaintextExport--OSPrompt--description--Linux": { + "messageformat": "Export", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog header the user will see on the polkit prompt before doing an export" + }, + "icu:PlaintextExport--OSPrompt--message--Linux": { + "messageformat": "Authentication is required to export your chat history.", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog text the user will see on the polkit prompt before doing an export" + }, + "icu:PlaintextExport--ProgressDialog--Header": { + "messageformat": "Exporting chat history", + "description": "Title of a progress dialog that shows while the export happens" + }, + "icu:PlaintextExport--ProgressDialog--Progress": { + "messageformat": "Exporting {currentBytes} of {totalBytes} ({percentage, number, percent})...", + "description": "Shown on progress dialog below the progress bar to provide the details of progress. currentBytes and totalBytes will be like 5 GB or 10 GB." + }, + "icu:PlaintextExport--ProgressDialog--TimeWarning": { + "messageformat": "This may take a few minutes", + "description": "Shown on progress dialog to explain that it might take a while" + }, + "icu:PlaintextExport--CompleteDialog--Header": { + "messageformat": "Export complete", + "description": "Title of the dialog shown once the export is complete" + }, + "icu:PlaintextExport--CompleteDialog--Description": { + "messageformat": "BE CAREFUL where you store your chat export file and do not share it with anyone. Other apps on your computer can access it depending on your computer’s permissions. ", + "description": "Additional explanation on the dialog shown when the export is complete" + }, + "icu:PlaintextExport--CompleteDialog--ShowFiles--Mac": { + "messageformat": "Show in Finder", + "description": "Button on the dialog shown when the export is complete to show the exported files - on a mac computer" + }, + "icu:PlaintextExport--CompleteDialog--ShowFiles--Linux": { + "messageformat": "Show in folder", + "description": "Button on the dialog shown when the export is complete to show the exported files - on a linux computer" + }, + "icu:PlaintextExport--CompleteDialog--ShowFiles--Windows": { + "messageformat": "Show in folder", + "description": "Button on the dialog shown when the export is complete to show the exported files - on a windows computer" + }, + "icu:PlaintextExport--Error--General--Title": { + "messageformat": "Couldn’t export chat history", + "description": "Title of dialog shown when a general plaintext export error has happened" + }, + "icu:PlaintextExport--Error--General--Description": { + "messageformat": "An error occurred and your chat history could not be exported.", + "description": "Detail text in dialog shown when a general plaintext export error has happened" + }, + "icu:PlaintextExport--Error--NotEnoughStorage--Title": { + "messageformat": "Not enough storage space", + "description": "Title of dialog shown when we detect that user doesn't have enough space for export" + }, + "icu:PlaintextExport--Error--NotEnoughStorage--Detail": { + "messageformat": "Your chat history can’t be exported because your computer doesn’t have enough free storage space. Free up {bytes} of space and then try again.", + "description": "Detail text in dialog shown when we detect that user doesn't have enough space for export. Bytes will be formatted like 12 MB" + }, + "icu:PlaintextExport--Error--RanOutOfStorage--Title": { + "messageformat": "Couldn’t export chat history", + "description": "Title of dialog shown when we attempted to save a file during export but disk is out of space" + }, + "icu:PlaintextExport--Error--RanOutOfStorage--Detail": { + "messageformat": "Your chat history couldn’t be exported because your computer doesn’t have enough free storage space. Free up {bytes} of space and then try again.", + "description": "Detail text in dialog shown when we attempted to save a file during export but disk is out of space. Bytes will formated like 12 MB" + }, + "icu:PlaintextExport--Error--DiskPermssions--Title": { + "messageformat": "Can’t export chat history", + "description": "Title of dialog shown when we attempted to save a file during export but got a permissions error" + }, + "icu:PlaintextExport--Error--DiskPermssions--Detail": { + "messageformat": "Your chat history can’t be exported because Signal doesn’t have permission to write files to disk. Try changing your disk's permissions or updating your system settings and then export again.", + "description": "Detail text in dialog shown when we attempted to save a file during export but got a permissions error" + }, "icu:NotificationProfile--moon-icon": { "messageformat": "Moon icon", "description": "Screenreader description for the moon icon used to signify notification profiles" @@ -7632,6 +7744,22 @@ "messageformat": "Done", "description": "Button to dismiss the backup key viewer, shown when reviewing your backup key for local on-device backups." }, + "icu:Preferences--local-backups--view-backup-key--os-prompt--mac": { + "messageformat": "show your backup key", + "description": "Shown as part of an OS prompt with a final string like 'Signal Desktop is trying to show your backup key'" + }, + "icu:Preferences--local-backups--view-backup-key--os-prompt--windows": { + "messageformat": "Verify your identity to view your backup key.", + "description": "Shown as the body text of an OS prompt with a title like \"Making sure it's you\"" + }, + "icu:Preferences--local-backups--view-backup-key--os-prompt-description--linux": { + "messageformat": "View backup key", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog header the user will see on the polkit prompt before showing the key" + }, + "icu:Preferences--local-backups--view-backup-key--os-prompt-message--linux": { + "messageformat": "Authentication is required to view your backup key.", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog text the user will see on the polkit prompt before showing the key" + }, "icu:Preferences--local-backups-backup-key-text-box": { "messageformat": "Backup key text box", "description": "ARIA label for the text box used to view or confirm the backup key for local message backups." @@ -7688,6 +7816,22 @@ "messageformat": "Backup key copied", "description": "Toast message after you copied the backup key to clipboard from settings for local on-device backups" }, + "icu:Preferences__local-backups--enable--os-prompt--mac": { + "messageformat": "enable backups", + "description": "Shown as part of an OS prompt with a final string like 'Signal Desktop is trying to enable backups'" + }, + "icu:Preferences__local-backups--enable--os-prompt--windows": { + "messageformat": "Verify your identity to enable backups.", + "description": "Shown as the body text of an OS prompt with a title like \"Making sure it's you\"" + }, + "icu:Preferences__local-backups--enable--os-prompt-description--linux": { + "messageformat": "Enable backups", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog header the user will see on the polkit prompt before enabling backups" + }, + "icu:Preferences__local-backups--enable--os-prompt-message--linux": { + "messageformat": "Authentication is required to enable backups.", + "description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog text the user will see on the polkit prompt before enabling backups" + }, "icu:Preferences__view-key": { "messageformat": "View key", "description": "Button to view the backup key which is used to restore a message history backup" diff --git a/app/main.main.ts b/app/main.main.ts index 9fd3394614..c1c9203e18 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -2807,6 +2807,7 @@ ipc.on('get-config', async event => { // paths crashDumpsPath: app.getPath('crashDumps'), + downloadsPath: app.getPath('downloads'), homePath: app.getPath('home'), installPath: app.getAppPath(), userDataPath: app.getPath('userData'), diff --git a/build/org.signalapp.enable-backups.policy b/build/policy-templates/org.signalapp.enable-backups.policy similarity index 79% rename from build/org.signalapp.enable-backups.policy rename to build/policy-templates/org.signalapp.enable-backups.policy index eda697aa9a..e58265d95f 100644 --- a/build/org.signalapp.enable-backups.policy +++ b/build/policy-templates/org.signalapp.enable-backups.policy @@ -4,12 +4,12 @@ Signal Desktop https://signal.org/ - Enable backups - Authentication is required to enable backups. + + auth_admin auth_admin auth_admin - \ No newline at end of file + diff --git a/build/policy-templates/org.signalapp.plaintext-export.policy b/build/policy-templates/org.signalapp.plaintext-export.policy new file mode 100644 index 0000000000..1c9faeec39 --- /dev/null +++ b/build/policy-templates/org.signalapp.plaintext-export.policy @@ -0,0 +1,16 @@ + + + + + Signal Desktop + https://signal.org/ + + + + + auth_admin + auth_admin + auth_admin + + + diff --git a/build/org.signalapp.view-aep.policy b/build/policy-templates/org.signalapp.view-aep.policy similarity index 78% rename from build/org.signalapp.view-aep.policy rename to build/policy-templates/org.signalapp.view-aep.policy index b68660d97b..9d7662b965 100644 --- a/build/org.signalapp.view-aep.policy +++ b/build/policy-templates/org.signalapp.view-aep.policy @@ -4,12 +4,12 @@ Signal Desktop https://signal.org/ - View backup key - Authentication is required to view your backup key. + + auth_admin auth_admin auth_admin - \ No newline at end of file + diff --git a/package.json b/package.json index 7d8e819501..e34a6602bd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "start": "electron .", "generate": "run-s generate:phase-0 generate:phase-1", "generate:phase-0": "run-p build:esbuild:scripts", - "generate:phase-1": "run-p --aggregate-output --print-label generate:phase-1:bundle build:icu-types build:compact-locales build:styles get-expire-time copy-components", + "generate:phase-1": "run-p --aggregate-output --print-label generate:phase-1:bundle build:icu-types build:compact-locales build:styles get-expire-time copy-components build:policy-files", "generate:phase-1:bundle": "run-s build-protobuf build:esbuild:bundle", "build-release": "pnpm run build", "sign-release": "node ts/updater/generateSignature.js", @@ -83,7 +83,7 @@ "test:storybook:test": "wait-on http://127.0.0.1:6006/ --timeout 5000 && test-storybook --testTimeout 60000", "build": "run-s --print-label generate build:esbuild:prod build:release", "build-win32-all": "run-s --print-label generate build:esbuild:prod build:release-win32-all", - "build-linux": "run-s generate build:esbuild:prod && pnpm run build:release --publish=never", + "build-linux": "run-s build:policy-files generate build:esbuild:prod && pnpm run build:release --publish=never", "build:acknowledgments": "node scripts/generate-acknowledgments.js", "build:dns-fallback": "node ts/scripts/generate-dns-fallback.node.js", "build:icu-types": "node ts/scripts/generate-icu-types.node.js", @@ -94,6 +94,7 @@ "build:esbuild:scripts": "node scripts/esbuild.js --no-bundle", "build:esbuild:bundle": "node scripts/esbuild.js --no-scripts", "build:esbuild:prod": "node scripts/esbuild.js --prod", + "build:policy-files": "node ts/scripts/gen-policy-files.node.js", "build:styles": "pnpm run \"/^build:styles:.*/\"", "build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7", "build:styles:tailwind": "tailwindcss -i ./stylesheets/tailwind-config.css -o ./stylesheets/tailwind.css", @@ -132,7 +133,7 @@ "@react-aria/utils": "3.25.3", "@react-spring/web": "9.7.5", "@react-types/shared": "3.27.0", - "@signalapp/libsignal-client": "0.83.0", + "@signalapp/libsignal-client": "0.86.3", "@signalapp/minimask": "1.0.1", "@signalapp/mute-state-change": "workspace:1.0.0", "@signalapp/quill-cjs": "2.1.2", diff --git a/patches/app-builder-lib.patch b/patches/app-builder-lib.patch index 50b3411954..8b473ed9e6 100644 --- a/patches/app-builder-lib.patch +++ b/patches/app-builder-lib.patch @@ -11,10 +11,10 @@ index 47e6f48fcbed88b6ac07cff15c888c1b8b59721f..76dd6cc7265054222f2d70c76aa8456d productFilename: packager.appInfo.productFilename, ...packager.platformSpecificBuildOptions, diff --git a/templates/linux/after-install.tpl b/templates/linux/after-install.tpl -index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d839ebdf8f8 100644 +index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a3cb08a6dc7970ab2b32c731f41ea6e471204a19 100644 --- a/templates/linux/after-install.tpl +++ b/templates/linux/after-install.tpl -@@ -55,3 +55,24 @@ if apparmor_status --enabled > /dev/null 2>&1; then +@@ -55,3 +55,26 @@ if apparmor_status --enabled > /dev/null 2>&1; then echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" fi fi @@ -27,10 +27,12 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83 + POLICY_ORG='org.signalapp' + POLICY_ENABLE_BACKUPS='enable-backups.policy' + POLICY_VIEW_AEP='view-aep.policy' ++ POLICY_EXPORT='plaintext-export.policy' + mkdir -p "$POLICY_TARGET_PATH"; + # Separate policies for staging and production builds + cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_ENABLE_BACKUPS" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_ENABLE_BACKUPS" + cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_VIEW_AEP" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_VIEW_AEP" ++ cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_EXPORT" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_EXPORT" +else + echo "Skipping installation of policies as polkit does not seem to be installed. This may affect the availability of some features."; +fi @@ -40,7 +42,7 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83 + +# SIGNAL CHANGES END diff --git a/templates/linux/after-remove.tpl b/templates/linux/after-remove.tpl -index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..b5011d1b8cdb741ba6453f942a3c0660b66d41a0 100644 +index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..676e76bae609cd3309be87a4606346f75945e900 100644 --- a/templates/linux/after-remove.tpl +++ b/templates/linux/after-remove.tpl @@ -13,3 +13,12 @@ APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' @@ -56,7 +58,6 @@ index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..b5011d1b8cdb741ba6453f942a3c0660 +fi + +# SIGNAL CHANGES END -\ No newline at end of file diff --git a/templates/nsis/include/installer.nsh b/templates/nsis/include/installer.nsh index 34e91dfe82fdbb2e929820f2e8deb771b7f7893c..73bfffc6c227a018cbbeb690d6d7b882ed142fc8 100644 --- a/templates/nsis/include/installer.nsh diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04cf3f6ce..c6a5d08548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ patchedDependencies: hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495 path: patches/@vitest+expect+2.0.5.patch app-builder-lib: - hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420 + hash: a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664 path: patches/app-builder-lib.patch casual@1.6.2: hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599 @@ -124,8 +124,8 @@ importers: specifier: 3.27.0 version: 3.27.0(react@18.3.1) '@signalapp/libsignal-client': - specifier: 0.83.0 - version: 0.83.0 + specifier: 0.86.3 + version: 0.86.3 '@signalapp/minimask': specifier: 1.0.1 version: 1.0.1 @@ -3487,8 +3487,8 @@ packages: '@signalapp/libsignal-client@0.76.7': resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==} - '@signalapp/libsignal-client@0.83.0': - resolution: {integrity: sha512-QaXviPAvj4PA2QDmN6YyPnlkp699BE3fIgaJmKrfvZMsvBfMGeJ3H3BHFt0CV2vUWMbc3oEgxbwdXu//f6oTrA==} + '@signalapp/libsignal-client@0.86.3': + resolution: {integrity: sha512-aN/pgT9YqacuABrtxBtBbQ0AMesZJIHVNqU8nUq75kRTleIU5aKeuOXt7ZHYUUJW7ot4O2n6O6eaMnMLbwBXFQ==} '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} @@ -14281,7 +14281,7 @@ snapshots: type-fest: 4.26.1 uuid: 11.0.2 - '@signalapp/libsignal-client@0.83.0': + '@signalapp/libsignal-client@0.86.3': dependencies: node-gyp-build: 4.8.4 type-fest: 4.26.1 @@ -15660,7 +15660,7 @@ snapshots: app-builder-bin@5.0.0-alpha.12: {} - app-builder-lib@26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14): + app-builder-lib@26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14): dependencies: '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 @@ -16879,7 +16879,7 @@ snapshots: dmg-builder@26.0.14(patch_hash=cb72ed47fa8d45513a36db33fcb41cb75c30cada4737da067bf3fa1f063725f2)(electron-builder-squirrel-windows@26.0.14): dependencies: - app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) + app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) builder-util: 26.0.13 builder-util-runtime: 9.3.2 fs-extra: 10.1.0 @@ -17025,7 +17025,7 @@ snapshots: electron-builder-squirrel-windows@26.0.14(dmg-builder@26.0.14): dependencies: - app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) + app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) builder-util: 26.0.13 electron-winstaller: 5.4.0 transitivePeerDependencies: @@ -17035,7 +17035,7 @@ snapshots: electron-builder@26.0.14(electron-builder-squirrel-windows@26.0.14): dependencies: - app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) + app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14) builder-util: 26.0.13 builder-util-runtime: 9.3.2 chalk: 4.1.2 diff --git a/protos/Backups.proto b/protos/Backups.proto index 241c48024c..fa9332f7df 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -5,6 +5,7 @@ syntax = "proto3"; package signalbackups; option java_package = "org.thoughtcrime.securesms.backup.v2.proto"; +option swift_prefix = "BackupProto_"; message BackupInfo { uint64 version = 1; @@ -12,6 +13,7 @@ message BackupInfo { bytes mediaRootBackupKey = 3; // 32-byte random value generated when the backup is uploaded for the first time. string currentAppVersion = 4; string firstAppVersion = 5; + bytes debugInfo = 6; // Client-specific data field for debug info during testing } // Frames must follow in the following ordering rules: @@ -68,6 +70,26 @@ message AccountData { Color color = 3; } + enum SentMediaQuality { + UNKNOWN_QUALITY = 0; // Interpret as "Standard" + STANDARD = 1; + HIGH = 2; + } + + message AutoDownloadSettings { + enum AutoDownloadOption { + UNKNOWN = 0; // Interpret as "Never" + NEVER = 1; + WIFI = 2; + WIFI_AND_CELLULAR = 3; + } + + AutoDownloadOption images = 1; + AutoDownloadOption audio = 2; + AutoDownloadOption video = 3; + AutoDownloadOption documents = 4; + } + message AccountSettings { bool readReceipts = 1; bool sealedSenderIndicators = 2; @@ -91,6 +113,12 @@ message AccountData { bool optimizeOnDeviceStorage = 20; // See zkgroup for integer particular values. Unset if backups are not enabled. optional uint64 backupTier = 21; + reserved /* showSealedSenderIndicators */ 22; + SentMediaQuality defaultSentMediaQuality = 23; + AutoDownloadSettings autoDownloadSettings = 24; + reserved /* wifiAutoDownloadSettings */ 25; + optional uint32 screenLockTimeoutMinutes = 26; // If unset, consider screen lock to be disabled. + optional bool pinReminders = 27; // If unset, consider pin reminders to be enabled. } message SubscriberData { @@ -111,6 +139,11 @@ message AccountData { } } + message AndroidSpecificSettings { + bool useSystemEmoji = 1; + bool screenshotSecurity = 2; + } + bytes profileKey = 1; optional string username = 2; UsernameLink usernameLink = 3; @@ -122,6 +155,9 @@ message AccountData { AccountSettings accountSettings = 9; IAPSubscriberData backupsSubscriberData = 10; string svrPin = 11; + AndroidSpecificSettings androidSpecificSettings = 12; + string bioText = 13; + string bioEmoji = 14; } message Recipient { @@ -344,7 +380,7 @@ message CallLink { string name = 3; Restrictions restrictions = 4; uint64 expirationMs = 5; - optional bytes epoch = 6; + optional bytes epoch = 6; // May be absent/empty for older links } message AdHocCall { @@ -695,7 +731,6 @@ message MessageAttachment { } message FilePointer { - message LocatorInfo { // Must be non-empty if transitCdnKey or plaintextHash are set/nonempty. // Otherwise must be empty. @@ -828,11 +863,6 @@ message Poll { repeated Reaction reactions = 5; } -message PollTerminateUpdate { - uint64 targetSentTimestamp = 1; - string question = 2; // Between 1-100 characters -} - message ChatUpdateMessage { // If unset, importers should ignore the update message without throwing an error. oneof update { @@ -1151,7 +1181,7 @@ message GroupJoinRequestCanceledUpdate { bytes requestorAci = 1; } -// A single requestor has requested to join and canceled +// A single requestor has requested to join and cancelled // their request repeatedly with no other updates in between. // The last action encompassed by this update is always a // cancellation; if there was another open request immediately @@ -1211,6 +1241,11 @@ message GroupExpirationTimerUpdate { optional bytes updaterAci = 2; } +message PollTerminateUpdate { + uint64 targetSentTimestamp = 1; + string question = 2; // Between 1-100 characters +} + message StickerPack { bytes packId = 1; bytes packKey = 2; diff --git a/ts/CI.preload.ts b/ts/CI.preload.ts index 7c8d9e84b0..5d92942c35 100644 --- a/ts/CI.preload.ts +++ b/ts/CI.preload.ts @@ -201,8 +201,13 @@ export function getCI({ } async function exportLocalBackup(backupsBaseDir: string): Promise { - const { snapshotDir } = - await backupsService.exportLocalBackup(backupsBaseDir); + const { snapshotDir } = await backupsService.exportLocalBackup( + backupsBaseDir, + { + type: 'local-encrypted', + localBackupSnapshotDir: backupsBaseDir, + } + ); return snapshotDir; } diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index ea9c666c86..6c41984eae 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import lodash from 'lodash'; +import semver from 'semver'; import type { getConfig } from './textsecure/WebAPI.preload.js'; import { createLogger } from './logging/log.std.js'; @@ -14,12 +15,21 @@ import { HashType } from './types/Crypto.std.js'; import { getCountryCode } from './types/PhoneNumber.std.js'; import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration.dom.js'; import type { StorageInterface } from './types/Storage.d.ts'; +import { ToastType } from './types/Toast.dom.js'; const { get, throttle } = lodash; const log = createLogger('RemoteConfig'); -const KnownConfigKeys = [ +// Semver flags must always be set to a valid semver (no empty enabled-only keys) +const SemverKeys = [ + 'desktop.plaintextExport.beta', + 'desktop.plaintextExport.prod', +] as const; + +export type SemverKeyType = (typeof SemverKeys)[number]; + +const ScalarKeys = [ 'desktop.chatFolders.alpha', 'desktop.chatFolders.beta', 'desktop.chatFolders.prod', @@ -56,6 +66,8 @@ const KnownConfigKeys = [ 'global.textAttachmentLimitBytes', ] as const; +const KnownConfigKeys = [...SemverKeys, ...ScalarKeys] as const; + export type ConfigKeyType = (typeof KnownConfigKeys)[number]; type ConfigValueType = { @@ -139,6 +151,7 @@ export const _refreshRemoteConfig = async ({ } const oldConfig = config; + let semverError = false; config = Array.from(newConfigValues.entries()).reduce( (acc, [name, value]) => { const enabled = value !== undefined && value.toLowerCase() !== 'false'; @@ -169,6 +182,17 @@ export const _refreshRemoteConfig = async ({ const hasChanged = previouslyEnabled !== enabled || previousValue !== configValue.value; + if ( + SemverKeys.includes(configValue.name as SemverKeyType) && + configValue.enabled && + (!configValue.value || !semver.parse(configValue.value)) + ) { + log.error( + `Key ${name} had invalid semver value '${configValue.value}'` + ); + semverError = true; + } + // If enablement changes at all, notify listeners const currentListeners = listeners[name] || []; if (hasChanged) { @@ -187,6 +211,12 @@ export const _refreshRemoteConfig = async ({ {} ); + if (semverError && config['desktop.internalUser']?.enabled) { + window.reduxActions.toast.showToast({ + toastType: ToastType.Error, + }); + } + const remoteExpirationValue = getValue('desktop.clientExpiration'); if (!remoteExpirationValue) { // If remote configuration fetch worked - we are not expired anymore. diff --git a/ts/axo/AxoAlertDialog.dom.tsx b/ts/axo/AxoAlertDialog.dom.tsx index 30254748ba..654cedfc84 100644 --- a/ts/axo/AxoAlertDialog.dom.tsx +++ b/ts/axo/AxoAlertDialog.dom.tsx @@ -215,7 +215,7 @@ export namespace AxoAlertDialog { * ---------------------------------- */ - export type ActionVariant = 'primary' | 'destructive'; + export type ActionVariant = 'primary' | 'secondary' | 'destructive'; export type ActionProps = Readonly<{ variant: ActionVariant; diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 1e3f20a481..83839c8887 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -363,6 +363,7 @@ export namespace AxoDialog { variant: ActionVariant; symbol?: AxoSymbol.InlineGlyphName; arrow?: boolean; + experimentalSpinner?: { 'aria-label': string } | null; onClick: () => void; children: ReactNode; }>; @@ -373,9 +374,10 @@ export namespace AxoDialog { variant={props.variant} symbol={props.symbol} arrow={props.arrow} + experimentalSpinner={props.experimentalSpinner} + onClick={props.onClick} size="md" width="grow" - onClick={props.onClick} > {props.children} diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index aecbdf4234..7ca3656791 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -156,6 +156,9 @@ export type PropsType = { // LowDiskSpaceBackupImportModal lowDiskSpaceBackupImportModal: { bytesNeeded: number } | null; hideLowDiskSpaceBackupImportModal: () => void; + // PlaintextExportWorkflow + shouldShowPlaintextExportWorkflow: boolean; + renderPlaintextExportWorkflow: () => JSX.Element; }; export function GlobalModalContainer({ @@ -255,13 +258,21 @@ export function GlobalModalContainer({ // LowDiskSpaceBackupImportModal lowDiskSpaceBackupImportModal, hideLowDiskSpaceBackupImportModal, + // PlaintextExportWorkflow + shouldShowPlaintextExportWorkflow, + renderPlaintextExportWorkflow, }: PropsType): JSX.Element | null { // We want the following dialogs to show in this order: + // 0. Stateful multi-modal workflows // 1. Errors // 2. Safety Number Changes // 3. Forward Modal, so other modals can open it // 4. The Rest (in no particular order, but they're ordered alphabetically) + if (shouldShowPlaintextExportWorkflow) { + return renderPlaintextExportWorkflow(); + } + // Errors if (errorModalProps) { return renderErrorModal(errorModalProps); diff --git a/ts/components/PlaintextExportWorkflow.dom.stories.tsx b/ts/components/PlaintextExportWorkflow.dom.stories.tsx new file mode 100644 index 0000000000..a36f3d5e65 --- /dev/null +++ b/ts/components/PlaintextExportWorkflow.dom.stories.tsx @@ -0,0 +1,175 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { PlaintextExportWorkflow } from './PlaintextExportWorkflow.dom.js'; +import { + PlaintextExportErrors, + PlaintextExportSteps, +} from '../types/Backups.std.js'; + +import type { PropsType } from './PlaintextExportWorkflow.dom.js'; +import type { ComponentMeta } from '../storybook/types.std.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/PlaintextExportWorkflow', + component: PlaintextExportWorkflow, + args: { + cancelWorkflow: action('cancelWorkflow'), + clearWorkflow: action('clearWorkflow'), + i18n, + openFileInFolder: action('openFileInFolder'), + osName: undefined, + verifyWithOSForExport: action('verifyWithOSForExport'), + workflow: { + step: PlaintextExportSteps.ConfirmingExport, + }, + }, +} satisfies ComponentMeta; + +export function ConfirmingExport(args: PropsType): JSX.Element { + return ; +} + +export function ConfirmingWithOS(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ChoosingLocation(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ExportingMessages(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ExportingAttachments(args: PropsType): JSX.Element { + return ( + + ); +} + +export function CompleteMac(args: PropsType): JSX.Element { + return ( + + ); +} + +export function CompleteLinux(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ErrorGeneric(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ErrorNotEnoughStorage(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ErrorRanOutOfStorage(args: PropsType): JSX.Element { + return ( + + ); +} + +export function ErrorStoragePermissions(args: PropsType): JSX.Element { + return ( + + ); +} diff --git a/ts/components/PlaintextExportWorkflow.dom.tsx b/ts/components/PlaintextExportWorkflow.dom.tsx new file mode 100644 index 0000000000..ec43f886f2 --- /dev/null +++ b/ts/components/PlaintextExportWorkflow.dom.tsx @@ -0,0 +1,306 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { + PlaintextExportErrors, + PlaintextExportSteps, +} from '../types/Backups.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 { LocalizerType } from '../types/I18N.std.js'; +import { AxoCheckbox } from '../axo/AxoCheckbox.dom.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 { I18n } from './I18n.dom.js'; + +export type PropsType = { + cancelWorkflow: () => unknown; + clearWorkflow: () => unknown; + i18n: LocalizerType; + openFileInFolder: (path: string) => unknown; + osName: 'linux' | 'macos' | 'windows' | undefined; + verifyWithOSForExport: (includeMedia: boolean) => unknown; + workflow: PlaintextExportWorkflowType; +}; + +function Bold(parts: Array) { + return {parts}; +} +function Secondary(parts: Array) { + return {parts}; +} + +export function PlaintextExportWorkflow({ + cancelWorkflow, + clearWorkflow, + i18n, + openFileInFolder, + osName, + verifyWithOSForExport, + workflow, +}: PropsType): JSX.Element { + const [includeMedia, setIncludeMedia] = React.useState(true); + const { step } = workflow; + + if ( + step === PlaintextExportSteps.ConfirmingExport || + step === PlaintextExportSteps.ConfirmingWithOS || + step === PlaintextExportSteps.ChoosingLocation + ) { + const shouldShowSpinner = step !== PlaintextExportSteps.ConfirmingExport; + + return ( + + + + +
+ {i18n('icu:PlaintextExport--Confirmation--Header')} +
+
+
+ +
+
+ +
+ +
+
+ + + + {i18n('icu:cancel')} + + verifyWithOSForExport(includeMedia)} + > + {i18n('icu:PlaintextExport--Confirmation--ContinueButton')} + + + +
+
+ ); + } + if ( + step === PlaintextExportSteps.ExportingMessages || + step === PlaintextExportSteps.ExportingAttachments + ) { + const progress = + step === PlaintextExportSteps.ExportingAttachments + ? workflow.progress + : undefined; + + let progressElements; + if (progress) { + const fractionComplete = + progress.totalBytes > 0 + ? progress.currentBytes / progress.totalBytes + : 0; + + progressElements = ( + <> +
+ +
+
+ {i18n('icu:PlaintextExport--ProgressDialog--Progress', { + currentBytes: formatFileSize(progress.currentBytes), + totalBytes: formatFileSize(progress.totalBytes), + percentage: fractionComplete, + })} +
+ + ); + } else { + progressElements = ( +
+ +
+ ); + } + + return ( + + + + +
+ {i18n('icu:PlaintextExport--ProgressDialog--Header')} +
+
+
+ +
+ {progressElements} +
+ {i18n('icu:PlaintextExport--ProgressDialog--TimeWarning')} +
+
+
+ +
+ + {i18n('icu:cancel')} + +
+
+
+
+ ); + } + if (step === PlaintextExportSteps.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 ( + + + + + {i18n('icu:PlaintextExport--CompleteDialog--Header')} + + + + + + + { + openFileInFolder(workflow.exportPath); + clearWorkflow(); + }} + > + {showInFolderText} + + + {i18n('icu:ok')} + + + + + ); + } + if (step === PlaintextExportSteps.Error) { + const { type } = workflow.errorDetails; + let title; + let detail; + + if (type === PlaintextExportErrors.General) { + title = i18n('icu:PlaintextExport--Error--General--Title'); + detail = i18n('icu:PlaintextExport--Error--General--Description'); + } else if (type === PlaintextExportErrors.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) { + title = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Title'); + detail = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Detail', { + bytes: formatFileSize(workflow.errorDetails.bytesNeeded), + }); + } else if (type === PlaintextExportErrors.StoragePermissions) { + title = i18n('icu:PlaintextExport--Error--DiskPermssions--Title'); + detail = i18n('icu:PlaintextExport--Error--DiskPermssions--Detail'); + } else { + throw missingCaseError(type); + } + + return ( + + + + {title} + {detail} + + + + {i18n('icu:ok')} + + + + + ); + } + + throw missingCaseError(step); +} diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index dae60da1d7..6e5d96ba37 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -117,6 +117,7 @@ const availableSpeakers = [ ]; const validateBackupResult: ExportResultType = { + attachmentBackupJobs: [], totalBytes: 100, duration: 10000, stats: { @@ -137,6 +138,7 @@ const validateBackupResult: ExportResultType = { const exportLocalBackupResult: LocalBackupExportResultType = { ...validateBackupResult, snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', + totalAttachmentBytes: 1000000, }; const donationAmountsConfig = { @@ -466,6 +468,7 @@ export default { isContentProtectionSupported: true, isContentProtectionNeeded: true, isMinimizeToAndStartInSystemTraySupported: true, + isPlaintextExportEnabled: true, lastSyncTime: Date.now(), localeOverride: null, localBackupFolder: undefined, @@ -614,6 +617,7 @@ export default { ), setSettingsLocation: action('setSettingsLocation'), showToast: action('showToast'), + startPlaintextExport: action('startPlaintextExport'), validateBackup: async () => { return { result: validateBackupResult, @@ -703,7 +707,13 @@ export const Donations = Template.bind({}); Donations.args = { settingsLocation: { page: SettingsPage.Donations }, }; - +export const ChatsWithDisabledPlaintextExport = Template.bind({}); +ChatsWithDisabledPlaintextExport.args = { + settingsLocation: { + page: SettingsPage.Chats, + }, + isPlaintextExportEnabled: false, +}; export const NotificationsPageWithThreeProfiles = Template.bind({}); const threeProfiles = [ { diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 3a5309a011..5311101fa2 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -192,6 +192,7 @@ export type PropsDataType = { isContentProtectionSupported: boolean; isHideMenuBarSupported: boolean; isNotificationAttentionSupported: boolean; + isPlaintextExportEnabled: boolean; isSyncSupported: boolean; isSystemTraySupported: boolean; isMinimizeToAndStartInSystemTraySupported: boolean; @@ -269,6 +270,7 @@ type PropsFunctionType = { ) => unknown; setSettingsLocation: (settingsLocation: SettingsLocation) => unknown; showToast: (toast: AnyToast) => unknown; + startPlaintextExport: () => unknown; validateBackup: () => Promise; internalAddDonationReceipt: (receipt: DonationReceipt) => void; @@ -439,6 +441,7 @@ export function Preferences({ isContentProtectionSupported, isHideMenuBarSupported, isNotificationAttentionSupported, + isPlaintextExportEnabled, isSyncSupported, isSystemTraySupported, isMinimizeToAndStartInSystemTraySupported, @@ -519,6 +522,7 @@ export function Preferences({ setSettingsLocation, shouldShowUpdateDialog, showToast, + startPlaintextExport, localeOverride, theme, themeSetting, @@ -1232,6 +1236,34 @@ export function Preferences({ )} + {isPlaintextExportEnabled && ( + + +
+ {i18n('icu:PlaintextExport--PreferencesRow--Header')} +
+
+ {i18n('icu:PlaintextExport--PreferencesRow--Description')} +
+ + } + right={ +
+ + {i18n('icu:PlaintextExport--ActionButton')} + +
+ } + /> +
+ )} + {isSyncSupported && ( { - static #instance: AttachmentLocalBackupManager | undefined; - readonly #jobsByMediaName = new Map(); - - static defaultParams: JobManagerParamsType = - { - markAllJobsInactive: AttachmentLocalBackupManager.markAllJobsInactive, - saveJob: AttachmentLocalBackupManager.saveJob, - removeJob: AttachmentLocalBackupManager.removeJob, - getNextJobs: AttachmentLocalBackupManager.getNextJobs, - runJob: runAttachmentBackupJob, - shouldHoldOffOnStartingQueuedJobs: () => { - const reduxState = window.reduxStore?.getState(); - if (reduxState) { - return isInCallSelector(reduxState); - } - return false; - }, - getJobId, - getJobIdForLogging, - getRetryConfig: () => RETRY_CONFIG, - maxConcurrentJobs: MAX_CONCURRENT_JOBS, - }; - - override logPrefix = 'AttachmentLocalBackupManager'; - - static get instance(): AttachmentLocalBackupManager { - if (!AttachmentLocalBackupManager.#instance) { - AttachmentLocalBackupManager.#instance = new AttachmentLocalBackupManager( - AttachmentLocalBackupManager.defaultParams - ); - } - return AttachmentLocalBackupManager.#instance; - } - - static get jobs(): Map { - return AttachmentLocalBackupManager.instance.#jobsByMediaName; - } - - static async start(): Promise { - log.info('starting'); - await AttachmentLocalBackupManager.instance.start(); - } - - static async stop(): Promise { - log.info('stopping'); - return AttachmentLocalBackupManager.#instance?.stop(); - } - - static async addJob(newJob: CoreAttachmentLocalBackupJobType): Promise { - return AttachmentLocalBackupManager.instance.addJob(newJob); - } - - static async waitForIdle(): Promise { - return AttachmentLocalBackupManager.instance.waitForIdle(); - } - - static async markAllJobsInactive(): Promise { - for (const [mediaName, job] of AttachmentLocalBackupManager.jobs) { - AttachmentLocalBackupManager.jobs.set(mediaName, { - ...job, - active: false, - }); - } - } - - static async saveJob(job: AttachmentLocalBackupJobType): Promise { - AttachmentLocalBackupManager.jobs.set(job.mediaName, job); - } - - static async removeJob( - job: Pick - ): Promise { - AttachmentLocalBackupManager.jobs.delete(job.mediaName); - } - - static clearAllJobs(): void { - AttachmentLocalBackupManager.jobs.clear(); - } - - static async getNextJobs({ - limit, - timestamp, - }: { - limit: number; - timestamp: number; - }): Promise> { - let countRemaining = limit; - const nextJobs: Array = []; - for (const job of AttachmentLocalBackupManager.jobs.values()) { - if (job.active || (job.retryAfter && job.retryAfter > timestamp)) { - continue; - } - - nextJobs.push(job); - countRemaining -= 1; - if (countRemaining <= 0) { - break; - } - } - return nextJobs; - } -} - -function getJobId(job: CoreAttachmentLocalBackupJobType): string { - return job.mediaName; -} - -function getJobIdForLogging(job: CoreAttachmentLocalBackupJobType): string { - return `${redactGenericText(job.mediaName)}`; -} - -/** - * Backup-specific methods - */ -class AttachmentPermanentlyMissingError extends Error {} +export class AttachmentPermanentlyMissingError extends Error {} type RunAttachmentBackupJobDependenciesType = { getAbsoluteAttachmentPath: typeof doGetAbsoluteAttachmentPath; - backupMediaBatch?: typeof doBackupMediaBatch; - backupsService: BackupsService; - encryptAndUploadAttachment: typeof encryptAndUploadAttachment; + getAbsoluteTempPath: typeof doGetAbsoluteAttachmentPath; decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; }; -export async function runAttachmentBackupJob( - job: AttachmentLocalBackupJobType, - _options: { - isLastAttempt: boolean; - abortSignal: AbortSignal; - }, - dependencies: RunAttachmentBackupJobDependenciesType = { - getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath, - backupsService, - backupMediaBatch: doBackupMediaBatch, - encryptAndUploadAttachment, - decryptAttachmentV2ToSink, - } -): Promise> { - const jobIdForLogging = getJobIdForLogging(job); - const logId = `AttachmentLocalBackupManager/runAttachmentBackupJob/${jobIdForLogging}`; - try { - await runAttachmentBackupJobInner(job, dependencies); - return { status: 'finished' }; - } catch (error) { - log.error( - `${logId}: Failed to backup attachment, attempt ${job.attempts}`, - Errors.toLogFormat(error) - ); - - if (error instanceof AttachmentPermanentlyMissingError) { - log.error(`${logId}: Attachment unable to be found, giving up on job`); - return { status: 'finished' }; - } - - return { status: 'retry' }; - } +export function getJobIdForLogging( + job: CoreAttachmentLocalBackupJobType +): string { + return `${redactGenericText(job.mediaName)}`; } -async function runAttachmentBackupJobInner( - job: AttachmentLocalBackupJobType, - dependencies: RunAttachmentBackupJobDependenciesType +export async function runAttachmentBackupJob( + job: CoreAttachmentLocalBackupJobType, + backupsBaseDir: string, + dependencies: RunAttachmentBackupJobDependenciesType = { + getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath, + getAbsoluteTempPath: doGetAbsoluteTempPath, + decryptAttachmentV2ToSink, + } ): Promise { const jobIdForLogging = getJobIdForLogging(job); - const logId = `AttachmentLocalBackupManager.runAttachmentBackupJobInner(${jobIdForLogging})`; + const logId = `AttachmentLocalBackupManager.runAttachmentBackupJob(${jobIdForLogging})`; - log.info(`${logId}: starting`); + log.info(`${logId}: starting...`); - const { backupsBaseDir, mediaName } = job; - const { path } = job.data; + const { isPlaintextExport, mediaName } = job; + const { contentType, fileName, localKey, path, size } = job.data; if (!path) { throw new AttachmentPermanentlyMissingError('No path property'); @@ -240,22 +71,93 @@ async function runAttachmentBackupJobInner( backupsBaseDir, mediaName, }); + await mkdir(localBackupFileDir, { recursive: true }); + const sourceAttachmentPath = dependencies.getAbsoluteAttachmentPath(path); const destinationLocalBackupFilePath = getLocalBackupPathForMediaName({ backupsBaseDir, mediaName, }); - // File is already encrypted with localKey, so we just have to copy it to the backup dir - const sourceAttachmentPath = getAbsoluteAttachmentPath(path); - const tempPath = getAbsoluteTempPath(createName()); + if (isPlaintextExport) { + const extension = getExtension(contentType, fileName); + const outPath = extension + ? `${destinationLocalBackupFilePath}.${extension}` + : destinationLocalBackupFilePath; + const outFileStream = createWriteStream(outPath); + await dependencies.decryptAttachmentV2ToSink( + { + ciphertextPath: sourceAttachmentPath, + idForLogging: 'AttachmentLocalBackupManager', + keysBase64: localKey, + size, + type: 'local', + }, + outFileStream + ); + } else { + // File is already encrypted with localKey, so we just copy it to the backup dir + const tempPath = dependencies.getAbsoluteTempPath(createName()); - // A unique constraint on the DB table should enforce that only one job is writing to - // the same mediaName at a time, but just to be safe, we copy to temp file and rename to - // ensure the atomicity of the copy operation + // A unique constraint on the DB table should enforce that only one job is writing to + // the same mediaName at a time, but just to be safe, we copy to temp file and rename + // to ensure the atomicity of the copy operation - // Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy) - await copyFile(sourceAttachmentPath, tempPath, FS_CONSTANTS.COPYFILE_FICLONE); - await rename(tempPath, destinationLocalBackupFilePath); + // Set COPYFILE_FICLONE for Copy on Write (OS dependent, graceful fallback to copy) + await copyFile( + sourceAttachmentPath, + tempPath, + FS_CONSTANTS.COPYFILE_FICLONE + ); + await rename(tempPath, destinationLocalBackupFilePath); + } + + log.info(`${logId}: complete!`); +} + +function getExtension( + contentType: string | undefined, + fileName: string | undefined +): string | undefined { + if (fileName) { + const extension = extname(fileName).replace(/^./, ''); + + if (extension) { + return extension; + } + } + + if (!contentType) { + return undefined; + } + + if (contentType.startsWith('application/x-')) { + return contentType.replace('application/x-', ''); + } + + if (contentType.startsWith('application/')) { + return contentType.replace('application/', ''); + } + + if (contentType.startsWith('audio/')) { + return contentType.replace('audio/', ''); + } + + if (contentType.startsWith('image/')) { + return contentType.replace('image/', ''); + } + + if (contentType === 'text/x-signal-plain') { + return 'txt'; + } + if (contentType.startsWith('text/x-')) { + return contentType.replace('text/x-', ''); + } + + if (contentType.startsWith('video/')) { + return contentType.replace('video/', ''); + } + + return undefined; } diff --git a/ts/scripts/gen-policy-files.node.ts b/ts/scripts/gen-policy-files.node.ts new file mode 100644 index 0000000000..f69f24b9ee --- /dev/null +++ b/ts/scripts/gen-policy-files.node.ts @@ -0,0 +1,110 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import fastGlob from 'fast-glob'; + +import { strictAssert } from '../util/assert.std.js'; + +const ROOT_DIR = path.join(__dirname, '..', '..'); + +async function main() { + const dirEntries = await fastGlob('_locales/*', { + cwd: ROOT_DIR, + onlyDirectories: true, + }); + + const english = JSON.parse( + await fs.readFile(path.join(ROOT_DIR, '_locales/en/messages.json'), 'utf8') + ); + const templateDir = path.join(ROOT_DIR, 'build/policy-templates'); + const templates = [ + { + name: 'org.signalapp.enable-backups.policy', + content: await fs.readFile( + path.join(templateDir, 'org.signalapp.enable-backups.policy'), + 'utf8' + ), + description: + 'icu:Preferences__local-backups--enable--os-prompt-description--linux', + message: + 'icu:Preferences__local-backups--enable--os-prompt-message--linux', + }, + { + name: 'org.signalapp.plaintext-export.policy', + content: await fs.readFile( + path.join(templateDir, 'org.signalapp.plaintext-export.policy'), + 'utf8' + ), + description: 'icu:PlaintextExport--OSPrompt--description--Linux', + message: 'icu:PlaintextExport--OSPrompt--message--Linux', + }, + { + name: 'org.signalapp.view-aep.policy', + content: await fs.readFile( + path.join(templateDir, 'org.signalapp.view-aep.policy'), + 'utf8' + ), + description: + 'icu:Preferences--local-backups--view-backup-key--os-prompt-description--linux', + message: + 'icu:Preferences--local-backups--view-backup-key--os-prompt-message--linux', + }, + ]; + + for (const template of templates) { + const englishDescription = english[template.description]?.messageformat; + strictAssert( + englishDescription, + `Must have english string for key ${template.description}` + ); + const englishMessage = english[template.message]?.messageformat; + strictAssert( + englishMessage, + `Must have english string for key ${template.message}` + ); + + let allDescriptions = `${englishDescription}\n`; + let allMessages = `${englishMessage}\n`; + + for (const dirEntry of dirEntries) { + const locale = path.basename(dirEntry); + const data = JSON.parse( + // eslint-disable-next-line no-await-in-loop + await fs.readFile( + path.join(ROOT_DIR, '_locales', locale, 'messages.json'), + 'utf8' + ) + ); + + const localeName = locale.replace('-', '_'); + const description = + data[template.description]?.messageformat ?? englishDescription; + allDescriptions += ` ${description}\n`; + + const message = data[template.message]?.messageformat ?? englishMessage; + allMessages += ` ${message}\n`; + } + + const targetPath = path.join(ROOT_DIR, 'build', template.name); + let targetContent = template.content; + + targetContent = targetContent.replace( + '', + allDescriptions + ); + targetContent = targetContent.replace( + '', + allMessages + ); + + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(targetPath, targetContent); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index 736a78fc5f..2f2724fcb2 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -3,7 +3,7 @@ import Long from 'long'; import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; -import { dirname } from 'node:path'; +import { BackupJsonExporter } from '@signalapp/libsignal-client/dist/MessageBackup.js'; import pMap from 'p-map'; import pTimeout from 'p-timeout'; import { Readable } from 'node:stream'; @@ -140,10 +140,9 @@ import { getFilePointerForAttachment } from './util/filePointers.preload.js'; import { getBackupMediaRootKey } from './crypto.preload.js'; import type { CoreAttachmentBackupJobType, - PartialAttachmentLocalBackupJobType, + CoreAttachmentLocalBackupJobType, } from '../../types/AttachmentBackup.std.js'; import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager.preload.js'; -import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager.preload.js'; import { getBackupCdnInfo, getLocalBackupFileNameForAttachment, @@ -196,6 +195,7 @@ const FLUSH_TIMEOUT = 30 * MINUTE; const REPORTING_THRESHOLD = SECOND; const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE; +const BACKUP_QUOTE_BODY_LIMIT = 2048; type GetRecipientIdOptionsType = | Readonly<{ @@ -270,11 +270,12 @@ export class BackupExportStream extends Readable { }; #ourConversation?: ConversationAttributesType; #attachmentBackupJobs: Array< - CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType + CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType > = []; #buffers = new Array(); #nextRecipientId = 1; #flushResolve: (() => void) | undefined; + #jsonExporter: BackupJsonExporter | undefined; // Map from custom color uuid to an index in accountSettings.customColors // array. @@ -289,7 +290,6 @@ export class BackupExportStream extends Readable { (async () => { log.info('BackupExportStream: starting...'); drop(AttachmentBackupManager.stop()); - drop(AttachmentLocalBackupManager.stop()); log.info('BackupExportStream: message migration starting...'); await migrateAllMessages(); @@ -303,34 +303,6 @@ export class BackupExportStream extends Readable { // TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction const { type } = this.options; switch (type) { - case 'local-encrypted': - { - log.info( - `BackupExportStream: Adding ${this.#attachmentBackupJobs.length} jobs for AttachmentLocalBackupManager` - ); - const backupsBaseDir = dirname( - this.options.localBackupSnapshotDir - ); - - AttachmentLocalBackupManager.clearAllJobs(); - await Promise.all( - this.#attachmentBackupJobs.map(job => { - if (job.type !== 'local') { - log.error( - "BackupExportStream: Can't enqueue remote backup jobs during local backup, skipping" - ); - return Promise.resolve(); - } - - return AttachmentLocalBackupManager.addJob({ - ...job, - backupsBaseDir, - }); - }) - ); - drop(AttachmentLocalBackupManager.start()); - } - break; case 'remote': await DataWriter.clearAllAttachmentBackupJobs(); await Promise.all( @@ -347,14 +319,21 @@ export class BackupExportStream extends Readable { ); }) ); - drop(AttachmentBackupManager.start()); + this.#attachmentBackupJobs = []; break; + case 'plaintext-export': + case 'local-encrypted': case 'cross-client-integration-test': + log.info( + `Type is ${this.options.type}, not doing anything with ${this.#attachmentBackupJobs.length} attachment jobs` + ); break; default: // eslint-disable-next-line no-unsafe-finally throw missingCaseError(type); } + + drop(AttachmentBackupManager.start()); log.info('BackupExportStream: finished'); } })() @@ -368,18 +347,29 @@ export class BackupExportStream extends Readable { public getStats(): Readonly { return this.#stats; } + public getAttachmentBackupJobs(): ReadonlyArray< + CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType + > { + return this.#attachmentBackupJobs; + } async #unsafeRun(): Promise { this.#ourConversation = window.ConversationController.getOurConversationOrThrow().attributes; + const backupInfo: Backups.IBackupInfo = { + version: Long.fromNumber(BACKUP_VERSION), + backupTimeMs: this.#backupTimeMs, + mediaRootBackupKey: getBackupMediaRootKey().serialize(), + firstAppVersion: itemStorage.get('restoredBackupFirstAppVersion'), + currentAppVersion: `Desktop ${window.getVersion()}`, + }; + this.push( - Backups.BackupInfo.encodeDelimited({ - version: Long.fromNumber(BACKUP_VERSION), - backupTimeMs: this.#backupTimeMs, - mediaRootBackupKey: getBackupMediaRootKey().serialize(), - firstAppVersion: itemStorage.get('restoredBackupFirstAppVersion'), - currentAppVersion: `Desktop ${window.getVersion()}`, - }).finish() + this.#getJsonIfNeeded( + this.options.type === 'plaintext-export' + ? Backups.BackupInfo.encode(backupInfo).finish() + : Backups.BackupInfo.encodeDelimited(backupInfo).finish() + ) ); this.#pushFrame({ @@ -779,6 +769,11 @@ export class BackupExportStream extends Readable { await pMap( messages, async message => { + if (skippedConversationIds.has(message.conversationId)) { + this.#stats.skippedMessages += 1; + return; + } + const chatItem = await this.#toChatItem(message, { aboutMe, callHistoryByCallId, @@ -817,6 +812,22 @@ export class BackupExportStream extends Readable { attachmentBackupJobs: this.#attachmentBackupJobs.length, }); + if (this.#jsonExporter) { + try { + const result = this.#jsonExporter.finish(); + if (result?.errorMessage) { + log.warn( + 'backups: jsonExporter.finish() returned validation error:', + result.errorMessage + ); + } + } catch (error) { + // We only warn because this isn't that big of a deal - the export is complete. + // All we need from the exporter at the end is any validation errors it found. + log.warn('backups: jsonExporter returned error', toLogFormat(error)); + } + } + this.push(null); } @@ -824,8 +835,50 @@ export class BackupExportStream extends Readable { this.#buffers.push(buffer); } + #frameToJson(encodedFrame: Uint8Array): string { + let lines = ''; + + if (!this.#jsonExporter) { + const { exporter, chunk: initialChunk } = BackupJsonExporter.start( + encodedFrame, + { validate: false } + ); + + lines = `${initialChunk}\n`; + this.#jsonExporter = exporter; + } else { + const results = this.#jsonExporter.exportFrames(encodedFrame); + for (const result of results) { + if (result.errorMessage) { + log.warn( + 'frameToJson: frame had a validation error:', + result.errorMessage + ); + } + if (!result.line) { + log.error('frameToJson: frame was filtered out by libsignal'); + } else { + lines += `${result.line}\n`; + } + } + } + + return lines; + } + + #getJsonIfNeeded(encoded: Uint8Array): Uint8Array { + if (this.options.type === 'plaintext-export') { + const json = this.#frameToJson(encoded); + return Buffer.from(json, 'utf-8'); + } + + return encoded; + } + #pushFrame(frame: Backups.IFrame): void { - this.#pushBuffer(Backups.Frame.encodeDelimited(frame).finish()); + const encodedFrame = Backups.Frame.encodeDelimited(frame).finish(); + const toPush = this.#getJsonIfNeeded(encodedFrame); + this.#pushBuffer(toPush); } async #flush(): Promise { @@ -893,6 +946,9 @@ export class BackupExportStream extends Readable { const backupsSubscriberData = generateBackupsSubscriberData(); const backupTier = itemStorage.get('backupTier'); + const autoDownloadPrimary = itemStorage.get( + 'auto-download-attachment-primary' + ); return { profileKey: itemStorage.get('profileKey'), @@ -921,6 +977,14 @@ export class BackupExportStream extends Readable { } : null, svrPin: itemStorage.get('svrPin'), + bioText: me.get('about'), + bioEmoji: me.get('aboutEmoji'), + // Test only values + ...(isTestOrMockEnvironment() + ? { + androidSpecificSettings: itemStorage.get('androidSpecificSettings'), + } + : {}), accountSettings: { readReceipts: itemStorage.get('read-receipt-setting'), sealedSenderIndicators: itemStorage.get('sealedSenderIndicators'), @@ -957,8 +1021,24 @@ export class BackupExportStream extends Readable { optimizeOnDeviceStorage: itemStorage.get( 'optimizeOnDeviceStorage' ), + pinReminders: itemStorage.get('pinReminders'), + screenLockTimeoutMinutes: itemStorage.get( + 'screenLockTimeoutMinutes' + ), + autoDownloadSettings: autoDownloadPrimary + ? { + images: autoDownloadPrimary.photos, + audio: autoDownloadPrimary.audio, + video: autoDownloadPrimary.videos, + documents: autoDownloadPrimary.documents, + } + : undefined, } : {}), + defaultSentMediaQuality: + itemStorage.get('sent-media-quality') === 'high' + ? Backups.AccountData.SentMediaQuality.HIGH + : Backups.AccountData.SentMediaQuality.STANDARD, }, }; } @@ -1279,6 +1359,11 @@ export class BackupExportStream extends Readable { } if (message.expireTimer) { + if (this.options.type === 'plaintext-export') { + // All disappearing messages are excluded in plaintext export + return undefined; + } + if (DurationInSeconds.toMillis(message.expireTimer) <= DAY) { // Message has an expire timer that's too short for export return undefined; @@ -2562,7 +2647,7 @@ export class BackupExportStream extends Readable { text: quote.text != null ? { - body: quote.text, + body: trimBody(quote.text, BACKUP_QUOTE_BODY_LIMIT), bodyRanges: quote.bodyRanges?.map(range => this.#toBodyRange(range) ), @@ -2670,7 +2755,10 @@ export class BackupExportStream extends Readable { }); let mediaName: string | undefined; - if (this.options.type === 'local-encrypted') { + if ( + this.options.type === 'local-encrypted' || + this.options.type === 'plaintext-export' + ) { if (hasRequiredInformationForLocalBackup(attachment)) { mediaName = getLocalBackupFileNameForAttachment(attachment); } @@ -3002,7 +3090,8 @@ export class BackupExportStream extends Readable { const attachment = message.attachments?.at(0); // Integration tests use the 'link-and-sync' version of export, which will include // view-once attachments - const shouldIncludeAttachments = isTestOrMockEnvironment(); + const shouldIncludeAttachments = + this.options.type !== 'plaintext-export' && isTestOrMockEnvironment(); return { attachment: !shouldIncludeAttachments || attachment == null diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index ce0b21390b..07eecab6ab 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -722,6 +722,9 @@ export class BackupImportStream extends Writable { donationSubscriberData, accountSettings, svrPin, + androidSpecificSettings, + bioText, + bioEmoji, }: Backups.IAccountData): Promise { strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); const me = { @@ -757,6 +760,12 @@ export class BackupImportStream extends Writable { if (familyName != null) { me.profileFamilyName = familyName; } + if (bioText != null) { + me.about = bioText; + } + if (bioEmoji != null) { + me.aboutEmoji = bioEmoji; + } if (avatarUrlPath != null) { await itemStorage.put('avatarUrl', avatarUrlPath); } @@ -776,6 +785,10 @@ export class BackupImportStream extends Writable { ); } } + if (isTestOrMockEnvironment()) { + // Only relevant for tests + await itemStorage.put('androidSpecificSettings', androidSpecificSettings); + } await saveBackupsSubscriberData(backupsSubscriberData); @@ -855,6 +868,26 @@ export class BackupImportStream extends Writable { 'optimizeOnDeviceStorage', accountSettings?.optimizeOnDeviceStorage === true ); + await itemStorage.put( + 'pinReminders', + dropNull(accountSettings?.pinReminders) + ); + await itemStorage.put( + 'screenLockTimeoutMinutes', + dropNull(accountSettings?.screenLockTimeoutMinutes) + ); + + const autoDownload = accountSettings?.autoDownloadSettings; + if (autoDownload) { + const autoDownloadEnum = + Backups.AccountData.AutoDownloadSettings.AutoDownloadOption; + await itemStorage.put('auto-download-attachment-primary', { + photos: autoDownload?.images || autoDownloadEnum.NEVER, + audio: autoDownload?.audio || autoDownloadEnum.NEVER, + videos: autoDownload?.video || autoDownloadEnum.NEVER, + documents: autoDownload?.documents || autoDownloadEnum.NEVER, + }); + } } this.#backupTier = accountSettings?.backupTier?.toNumber(); @@ -863,6 +896,18 @@ export class BackupImportStream extends Writable { accountSettings?.backupTier?.toNumber() ); + await itemStorage.put( + 'sealedSenderIndicators', + accountSettings?.sealedSenderIndicators === true + ); + await itemStorage.put( + 'sent-media-quality', + accountSettings?.defaultSentMediaQuality === + Backups.AccountData.SentMediaQuality.HIGH + ? 'high' + : 'standard' + ); + const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData; switch (accountSettings?.phoneNumberSharingMode) { case BackupMode.EVERYBODY: diff --git a/ts/services/backups/index.preload.ts b/ts/services/backups/index.preload.ts index 634ecb7cbe..3ed176b2f8 100644 --- a/ts/services/backups/index.preload.ts +++ b/ts/services/backups/index.preload.ts @@ -5,7 +5,7 @@ import { pipeline } from 'node:stream/promises'; import { PassThrough } from 'node:stream'; import type { Readable, Writable } from 'node:stream'; import { createReadStream, createWriteStream } from 'node:fs'; -import { mkdir, stat, unlink } from 'node:fs/promises'; +import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; import fsExtra from 'fs-extra'; import { join } from 'node:path'; import { createGzip, createGunzip } from 'node:zlib'; @@ -74,6 +74,7 @@ import type { BackupImportOptions, ExportResultType, LocalBackupExportResultType, + OnProgressCallback, } from './types.std.js'; import { BackupInstallerError, @@ -94,11 +95,27 @@ import { readLocalBackupFilesList, validateLocalBackupStructure, } from './util/localBackup.node.js'; -import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager.preload.js'; +import { + AttachmentPermanentlyMissingError, + getJobIdForLogging, + runAttachmentBackupJob, +} from '../../jobs/AttachmentLocalBackupManager.preload.js'; import { decipherWithAesKey } from '../../util/decipherWithAesKey.node.js'; import { areRemoteBackupsTurnedOn } from '../../util/isBackupEnabled.preload.js'; -import { unlink as unlinkAccount } from '../../textsecure/WebAPI.preload.js'; +import { + isOnline, + unlink as unlinkAccount, +} from '../../textsecure/WebAPI.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { LOCAL_BACKUP_VERSION } from './constants.std.js'; +import { getTimestampForFolder } from '../../util/timestamp.std.js'; +import { MEBIBYTE } from '../../types/AttachmentSize.std.js'; +import { + NotEnoughStorageError, + RanOutOfStorageError, + StoragePermissionsError, +} from '../../types/Backups.std.js'; +import { getFreeDiskSpace } from '../../util/getFreeDiskSpace.node.js'; const { ensureFile } = fsExtra; @@ -320,42 +337,148 @@ export class BackupsService { } public async exportLocalBackup( - backupsBaseDir: string | undefined = undefined + backupsBaseDir: string, + options: BackupExportOptions ): Promise { strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled'); - await this.#waitForEmptyQueues('backups.exportLocalBackup'); + if (isOnline()) { + await this.#waitForEmptyQueues('backups.exportLocalBackup'); + } else { + log.info('exportLocalBackup: Offline; skipping wait for empty queues'); + } const baseDir = backupsBaseDir ?? join(window.SignalContext.getPath('userData'), 'SignalBackups'); - const snapshotDir = join(baseDir, `signal-backup-${new Date().getTime()}`); + const snapshotDir = join( + baseDir, + `signal-backup-${getTimestampForFolder()}` + ); await mkdir(snapshotDir, { recursive: true }); - const mainProtoPath = join(snapshotDir, 'main'); - log.info('exportLocalBackup: starting'); + const isPlaintextExport = options.type === 'plaintext-export'; + const mainProtoPath = join( + snapshotDir, + isPlaintextExport ? 'main.jsonl' : 'main' + ); - const exportResult = await this.exportToDisk(mainProtoPath, { - type: 'local-encrypted', - localBackupSnapshotDir: snapshotDir, - }); + log.info(`exportLocalBackup: starting with type=${options.type}`); + + const exportResult = await this.exportToDisk( + mainProtoPath, + options.type === 'local-encrypted' + ? { + ...options, + localBackupSnapshotDir: snapshotDir, + } + : options + ); + + const { attachmentBackupJobs } = exportResult; + let totalAttachmentBytes = 0; + if (options.type === 'plaintext-export' && !options.shouldIncludeMedia) { + log.info( + `BackupExportStream: shouldIncludeMedia=false, not adding ${attachmentBackupJobs.length} attachment jobs` + ); + } else if ( + options.type === 'plaintext-export' || + options.type === 'local-encrypted' + ) { + const onProgress = + options.type === 'plaintext-export' ? options.onProgress : undefined; + const abortSignal = + options.type === 'plaintext-export' ? options.abortSignal : undefined; + let currentBytes = 0; + + log.info( + `BackupExportStream: About to process ${attachmentBackupJobs.length} jobs` + ); + for (const job of attachmentBackupJobs) { + if (job.type !== 'local') { + log.error( + "BackupExportStream: Can't process remote backup jobs during local backup, skipping" + ); + continue; + } + + totalAttachmentBytes += job.data.size; + } + + const freeSpaceBytes = await getFreeDiskSpace(baseDir); + const bufferBytes = 100 * MEBIBYTE; + const bytesNeeded = totalAttachmentBytes + bufferBytes - freeSpaceBytes; + if (bytesNeeded > 0) { + log.info( + `exportLocalBackup: Not enough storage; only ${freeSpaceBytes} available, ${totalAttachmentBytes} of attachments to export` + ); + throw new NotEnoughStorageError(bytesNeeded); + } + + for (const job of attachmentBackupJobs) { + if (job.type !== 'local') { + log.error( + 'exportLocalBackup: Cannot process remote backup jobs during local backup, skipping' + ); + continue; + } + + if (abortSignal?.aborted) { + log.info( + 'exportLocalBackup: Aborted; exiting before processing all attachment jobs' + ); + throw new Error('User aborted the export!'); + } + + try { + // eslint-disable-next-line no-await-in-loop + await runAttachmentBackupJob(job, baseDir); + + currentBytes += job.data.size; + onProgress?.(currentBytes, totalAttachmentBytes); + } catch (error) { + if (error instanceof AttachmentPermanentlyMissingError) { + log.error( + `${getJobIdForLogging(job)}: Attachment was not found; continuing with export` + ); + currentBytes += job.data.size; + continue; + } + + const stillToExportBytes = totalAttachmentBytes - currentBytes; + if (error.code === 'ENOSPC') { + throw new RanOutOfStorageError(stillToExportBytes); + } + if (error.code === 'EPERM' || error.code === 'EACCES') { + throw new StoragePermissionsError(); + } + + throw error; + } + } + } log.info('exportLocalBackup: writing metadata'); - const metadataArgs = { - snapshotDir, - backupId: getBackupId(), - metadataKey: getLocalBackupMetadataKey(), - }; - await writeLocalBackupMetadata(metadataArgs); - await verifyLocalBackupMetadata(metadataArgs); - - log.info( - 'exportLocalBackup: waiting for AttachmentLocalBackupManager to finish' - ); - await AttachmentLocalBackupManager.waitForIdle(); + if (isPlaintextExport) { + const metadataPath = join(snapshotDir, 'metadata.json'); + await writeFile( + metadataPath, + JSON.stringify({ + version: LOCAL_BACKUP_VERSION, + }) + ); + } else { + const metadataArgs = { + snapshotDir, + backupId: getBackupId(), + metadataKey: getLocalBackupMetadataKey(), + }; + await writeLocalBackupMetadata(metadataArgs); + await verifyLocalBackupMetadata(metadataArgs); + } log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`); - return { ...exportResult, snapshotDir }; + return { ...exportResult, snapshotDir, totalAttachmentBytes }; } public async stageLocalBackupForImport( @@ -431,7 +554,7 @@ export class BackupsService { options ); - if (options.type !== 'cross-client-integration-test') { + if (options.type === 'local-encrypted' || options.type === 'remote') { await validateBackup( () => new FileStream(path), exportResult.totalBytes, @@ -453,13 +576,68 @@ export class BackupsService { return { error: 'Backups directory not selected' }; } - const result = await this.exportLocalBackup(backupsBaseDir); + const result = await this.exportLocalBackup(backupsBaseDir, { + type: 'local-encrypted', + localBackupSnapshotDir: backupsBaseDir, + }); return { result }; } catch (error) { return { error: Errors.toLogFormat(error) }; } } + public async exportPlaintext({ + abortSignal, + onProgress, + shouldIncludeMedia, + targetPath, + }: { + abortSignal: AbortSignal; + onProgress: OnProgressCallback; + shouldIncludeMedia: boolean; + targetPath: string; + }): Promise { + try { + log.info('exportPlaintext starting...'); + + const freeSpaceBytes = await getFreeDiskSpace(targetPath); + const minimumBytes = 200 * MEBIBYTE; + const bytesNeeded = minimumBytes - freeSpaceBytes; + if (bytesNeeded > 0) { + log.info( + `exportPlaintext: Not enough storage; only ${freeSpaceBytes} available, ${minimumBytes} is minimum needed` + ); + throw new NotEnoughStorageError(bytesNeeded); + } + + const exportDir = join( + targetPath, + `signal-export-${getTimestampForFolder()}` + ); + + await mkdir(exportDir, { recursive: true }); + + const result = await this.exportLocalBackup(exportDir, { + abortSignal, + type: 'plaintext-export', + onProgress, + shouldIncludeMedia, + }); + + log.info('exportPlaintext complete!'); + return { + ...result, + snapshotDir: exportDir, + }; + } catch (error) { + if (error.code === 'EPERM' || error.code === 'EACCES') { + throw new StoragePermissionsError(); + } + + throw error; + } + } + public async _internalStageLocalBackupForImport(): Promise { const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke( 'show-open-folder-dialog' @@ -492,7 +670,12 @@ export class BackupsService { const duration = Date.now() - start; return { - result: { duration, stats: recordStream.getStats(), totalBytes }, + result: { + attachmentBackupJobs: recordStream.getAttachmentBackupJobs(), + duration, + stats: recordStream.getStats(), + totalBytes, + }, }; } catch (error) { return { error: Errors.toLogFormat(error) }; @@ -902,12 +1085,20 @@ export class BackupsService { if (window.SignalCI || options.type === 'cross-client-integration-test') { strictAssert( isTestOrMockEnvironment(), - 'Plaintext backups can be exported only in test harness' + 'exportBackup: Plaintext backups can be exported only in test harness' + ); + } else if ( + !isOnline() && + (options.type === 'local-encrypted' || + options.type === 'plaintext-export') + ) { + log.info( + `exportBackup: Skipping CDN update; offline at type is ${options.type}` ); } else { // We first fetch the latest info on what's on the CDN, since this affects the // filePointers we will generate during export - log.info('Fetching latest backup CDN metadata'); + log.info('exportBackup: Fetching latest backup CDN metadata'); await this.fetchAndSaveBackupCdnObjectMetadata(); } @@ -942,9 +1133,28 @@ export class BackupsService { case 'cross-client-integration-test': strictAssert( isTestOrMockEnvironment(), - 'Plaintext backups can be exported only in test harness' + 'exportBackup: Plaintext backups can be exported only in test harness' + ); + await pipeline( + recordStream, + measureSize({ + onComplete: size => { + totalBytes = size; + }, + }), + sink + ); + break; + case 'plaintext-export': + await pipeline( + recordStream, + measureSize({ + onComplete: size => { + totalBytes = size; + }, + }), + sink ); - await pipeline(recordStream, sink); break; default: throw missingCaseError(type); @@ -966,7 +1176,12 @@ export class BackupsService { } const duration = Date.now() - start; - return { totalBytes, stats: recordStream.getStats(), duration }; + return { + attachmentBackupJobs: recordStream.getAttachmentBackupJobs(), + totalBytes, + stats: recordStream.getStats(), + duration, + }; } finally { log.info('exportBackup: finished...'); this.#isRunning = false; diff --git a/ts/services/backups/types.std.ts b/ts/services/backups/types.std.ts index 129fd0843c..7dd27539af 100644 --- a/ts/services/backups/types.std.ts +++ b/ts/services/backups/types.std.ts @@ -3,6 +3,10 @@ import type { AciString, PniString } from '../../types/ServiceId.std.js'; import type { ConversationColorType } from '../../types/Colors.std.js'; +import type { + CoreAttachmentBackupJobType, + CoreAttachmentLocalBackupJobType, +} from '../../types/AttachmentBackup.std.js'; // Duplicated here to allow loading it in a non-node environment export enum BackupLevel { @@ -25,8 +29,22 @@ export type AboutMe = { e164?: string; }; +export type OnProgressCallback = ( + currentBytes: number, + totalBytes: number +) => void; + export type BackupExportOptions = - | { type: 'remote' | 'cross-client-integration-test'; level: BackupLevel } + | { + type: 'remote' | 'cross-client-integration-test'; + level: BackupLevel; + } + | { + type: 'plaintext-export'; + abortSignal: AbortSignal; + onProgress: OnProgressCallback; + shouldIncludeMedia: boolean; + } | { type: 'local-encrypted'; localBackupSnapshotDir: string; @@ -39,7 +57,7 @@ export type BackupImportOptions = ( } ) & { ephemeralKey?: Uint8Array; - onProgress?: (currentBytes: number, totalBytes: number) => void; + onProgress?: OnProgressCallback; }; export type LocalChatStyle = Readonly<{ @@ -66,6 +84,9 @@ export type StatsType = { }; export type ExportResultType = Readonly<{ + attachmentBackupJobs: ReadonlyArray< + CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType + >; totalBytes: number; duration: number; stats: Readonly; @@ -73,4 +94,5 @@ export type ExportResultType = Readonly<{ export type LocalBackupExportResultType = ExportResultType & { snapshotDir: string; + totalAttachmentBytes: number; }; diff --git a/ts/services/backups/util/filePointers.preload.ts b/ts/services/backups/util/filePointers.preload.ts index 0452270dd5..3068917b23 100644 --- a/ts/services/backups/util/filePointers.preload.ts +++ b/ts/services/backups/util/filePointers.preload.ts @@ -22,7 +22,7 @@ import { import { strictAssert } from '../../../util/assert.std.js'; import type { CoreAttachmentBackupJobType, - PartialAttachmentLocalBackupJobType, + CoreAttachmentLocalBackupJobType, } from '../../../types/AttachmentBackup.std.js'; import { type GetBackupCdnInfoType, @@ -208,7 +208,7 @@ export async function getFilePointerForAttachment({ messageReceivedAt: number; }): Promise<{ filePointer: Backups.FilePointer; - backupJob?: CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType; + backupJob?: CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType; }> { const attachment = maybeFixupAttachment(rawAttachment); @@ -246,7 +246,9 @@ export async function getFilePointerForAttachment({ ? await getBackupCdnInfo(remoteMediaId.string) : { isInBackupTier: false }; - const isLocalBackup = backupOptions.type === 'local-encrypted'; + const isLocalBackup = + backupOptions.type === 'local-encrypted' || + backupOptions.type === 'plaintext-export'; filePointer.locatorInfo = getLocatorInfoForAttachment({ attachment, backupOptions, @@ -262,10 +264,15 @@ export async function getFilePointerForAttachment({ return { filePointer, backupJob: { + isPlaintextExport: backupOptions.type === 'plaintext-export', mediaName: getLocalBackupFileNameForAttachment(attachment), type: 'local', data: { + contentType: attachment.contentType, + fileName: attachment.fileName, + localKey: attachment.localKey, path: attachment.path, + size: attachment.size, }, }, }; @@ -362,7 +369,9 @@ function getLocatorInfoForAttachment({ }): Backups.FilePointer.LocatorInfo { const locatorInfo = new Backups.FilePointer.LocatorInfo(); - const isLocalBackup = backupOptions.type === 'local-encrypted'; + const isLocalBackup = + backupOptions.type === 'local-encrypted' || + backupOptions.type === 'plaintext-export'; const shouldBeLocallyBackedUp = isLocalBackup && diff --git a/ts/services/backups/validator.preload.ts b/ts/services/backups/validator.preload.ts index c6653a1802..b45dc3d8ce 100644 --- a/ts/services/backups/validator.preload.ts +++ b/ts/services/backups/validator.preload.ts @@ -46,7 +46,10 @@ export async function validateBackup( `Backup validation failed: ${outcome.errorMessage}` ); } else if (type === ValidationType.Export) { - strictAssert(outcome.ok, 'Backup validation failed'); + strictAssert( + outcome.ok, + `Backup validation failed: ${outcome.errorMessage}` + ); } else { throw missingCaseError(type); } diff --git a/ts/services/expiring/createExpiringEntityCleanupService.std.ts b/ts/services/expiring/createExpiringEntityCleanupService.std.ts index 504648579e..179891edee 100644 --- a/ts/services/expiring/createExpiringEntityCleanupService.std.ts +++ b/ts/services/expiring/createExpiringEntityCleanupService.std.ts @@ -70,7 +70,7 @@ export function createExpiringEntityCleanupService( function cancelNextScheduledRun(reason: string) { if (controller != null) { - log.warn(`cancel(${reason}) cancelling next scheduled run`); + log.warn(`cancel(${reason}) canceling next scheduled run`); controller.abort(reason); controller = null; } diff --git a/ts/state/actions.preload.ts b/ts/state/actions.preload.ts index 63c8d098aa..f806716a32 100644 --- a/ts/state/actions.preload.ts +++ b/ts/state/actions.preload.ts @@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts.preload.js'; import { actions as app } from './ducks/app.preload.js'; import { actions as audioPlayer } from './ducks/audioPlayer.preload.js'; import { actions as audioRecorder } from './ducks/audioRecorder.preload.js'; +import { actions as backups } from './ducks/backups.preload.js'; import { actions as badges } from './ducks/badges.preload.js'; import { actions as callHistory } from './ducks/callHistory.preload.js'; import { actions as calling } from './ducks/calling.preload.js'; @@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = { app, audioPlayer, audioRecorder, + backups, badges, callHistory, calling, diff --git a/ts/state/ducks/backups.preload.ts b/ts/state/ducks/backups.preload.ts new file mode 100644 index 0000000000..6ac4f237e4 --- /dev/null +++ b/ts/state/ducks/backups.preload.ts @@ -0,0 +1,413 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { debounce } from 'lodash'; +import { ipcRenderer } from 'electron'; + +import type { ThunkAction } from 'redux-thunk'; +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 { 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, + PlaintextExportSteps, + RanOutOfStorageError, + StoragePermissionsError, + validTransitions, +} from '../../types/Backups.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'; +import type { LocalizerType } from '../../types/I18N.std.js'; +import { toLogFormat } from '../../types/errors.std.js'; + +const log = createLogger('ducks/backups'); + +// State + +// We expect there to be other backup workflows, but only one can be active at once +export type WorkflowContainer = ReadonlyDeep< + | { + type: 'plaintext-export'; + workflow: PlaintextExportWorkflowType; + } + | undefined +>; + +export type BackupsStateType = ReadonlyDeep<{ + workflow: WorkflowContainer; +}>; + +// Actions + +const SET_WORKFLOW = 'Backup/SET_WORKFLOW'; + +export type SetWorkflowAction = ReadonlyDeep<{ + type: typeof SET_WORKFLOW; + payload: WorkflowContainer; +}>; + +type BackupsActionTGype = ReadonlyDeep; + +// Action Creators + +export const actions = { + cancelWorkflow, + clearWorkflow, + setWorkflow, + startPlaintextExport, + verifyWithOSForExport, +}; + +export const useBackupActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +// Generally this won't be used directly +function setWorkflow(payload: WorkflowContainer): SetWorkflowAction { + return { + type: SET_WORKFLOW, + payload, + }; +} + +function clearWorkflow(): SetWorkflowAction { + return { + type: SET_WORKFLOW, + payload: undefined, + }; +} +function startPlaintextExport(): ThunkAction< + void, + StateType, + unknown, + SetWorkflowAction +> { + return async (dispatch, getState) => { + const state = getBackups(getState()); + if (state.workflow != null) { + log.error( + `startPlaintextExport: Cannot start, workflow is already ${state.workflow.type}/${state.workflow.workflow.step}` + ); + return; + } + + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.ConfirmingExport, + }, + }, + }); + }; +} + +export function verifyWithOSForExport( + includeMedia: boolean +): ThunkAction { + return async (dispatch, getState) => { + const previousWorkflow = getWorkflow(getState()); + if ( + !previousWorkflow || + previousWorkflow.step !== PlaintextExportSteps.ConfirmingExport + ) { + log.error( + `verifyWithOSForExport: Cannot start, previous state is ${previousWorkflow?.step}` + ); + return; + } + + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.ConfirmingWithOS, + includeMedia, + }, + }, + }); + + const result = await promptOSAuth('plaintext-export'); + switch (result) { + case 'success': + chooseExportLocation()(dispatch, getState, null); + break; + case 'unauthorized-no-windows-ucv': + case 'unsupported': + case 'error': + log.warn( + `verifyWithOSForExport: Got '${result}' status, but continuing on` + ); + chooseExportLocation()(dispatch, getState, null); + break; + case 'unauthorized': + log.warn('verifyWithOSForExport: Not authorized; clearing workflow'); + dispatch(clearWorkflow()); + break; + default: + throw missingCaseError(result); + } + }; +} + +function chooseExportLocation(): ThunkAction< + void, + StateType, + unknown, + SetWorkflowAction +> { + return async (dispatch, getState) => { + const previousWorkflow = getWorkflow(getState()); + if ( + !previousWorkflow || + previousWorkflow.step !== PlaintextExportSteps.ConfirmingWithOS + ) { + log.error( + `chooseExportLocation: Cannot start, previous state is ${previousWorkflow?.step}` + ); + return; + } + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.ChoosingLocation, + includeMedia: previousWorkflow.includeMedia, + }, + }, + }); + + const i18n = getIntl(getState()); + const result = await showExportLocationChooser(i18n); + + if (result.canceled || !result.dirPath) { + dispatch(clearWorkflow()); + } else { + doPlaintextExport(result.dirPath)(dispatch, getState, null); + } + }; +} + +function doPlaintextExport( + exportPath: string +): ThunkAction { + return async (dispatch, getState) => { + const previousWorkflow = getWorkflow(getState()); + if ( + !previousWorkflow || + previousWorkflow.step !== PlaintextExportSteps.ChoosingLocation + ) { + log.error( + `doPlaintextExport: Cannot start, previous state is ${previousWorkflow?.step}` + ); + return; + } + + const { includeMedia } = previousWorkflow; + + const abortController = new AbortController(); + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.ExportingMessages, + abortController, + exportPath, + exportInBackground: false, + }, + }, + }); + + try { + let complete = false; + const onProgress = debounce( + (currentBytes, totalBytes) => { + if (complete) { + return; + } + if (abortController.signal.aborted) { + return; + } + + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.ExportingAttachments, + abortController, + progress: { + currentBytes, + totalBytes, + }, + exportPath, + exportInBackground: false, + }, + }, + }); + }, + 200, + { leading: true, trailing: true, maxWait: 200 } + ); + + const result = await backupsService.exportPlaintext({ + abortSignal: abortController.signal, + onProgress, + shouldIncludeMedia: includeMedia, + targetPath: exportPath, + }); + complete = true; + + if (abortController.signal.aborted) { + dispatch(clearWorkflow()); + return; + } + + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.Complete, + exportPath: result.snapshotDir || exportPath, + }, + }, + }); + } catch (error) { + log.warn('doPlaintextExport:', toLogFormat(error)); + + if (abortController.signal.aborted) { + dispatch(clearWorkflow()); + return; + } + + let errorDetails: PlaintextExportErrorDetails = { + type: PlaintextExportErrors.General, + }; + + if (error instanceof NotEnoughStorageError) { + errorDetails = { + type: PlaintextExportErrors.NotEnoughStorage, + bytesNeeded: error.bytesNeeded, + }; + } + if (error instanceof RanOutOfStorageError) { + errorDetails = { + type: PlaintextExportErrors.RanOutOfStorage, + bytesNeeded: error.bytesNeeded, + }; + } + if (error instanceof StoragePermissionsError) { + errorDetails = { + type: PlaintextExportErrors.StoragePermissions, + }; + } + + dispatch({ + type: SET_WORKFLOW, + payload: { + type: 'plaintext-export', + workflow: { + step: PlaintextExportSteps.Error, + errorDetails, + }, + }, + }); + } + }; +} + +function cancelWorkflow(): ThunkAction< + void, + StateType, + unknown, + SetWorkflowAction +> { + return async (dispatch, getState) => { + const previousWorkflow = getWorkflow(getState()); + if ( + !previousWorkflow || + (previousWorkflow.step !== PlaintextExportSteps.ExportingMessages && + previousWorkflow.step !== PlaintextExportSteps.ExportingAttachments) + ) { + log.error( + `cancelWorkflow: Cannot cancel, previous state is ${previousWorkflow?.step}` + ); + return; + } + + const { abortController } = previousWorkflow; + abortController.abort(); + + dispatch(clearWorkflow()); + }; +} + +function showExportLocationChooser(i18n: LocalizerType): Promise<{ + canceled: boolean; + dirPath?: string; +}> { + return ipcRenderer.invoke('show-open-folder-dialog', { + useMainWindow: true, + title: i18n('icu:SaveMultiDialog__title'), + buttonLabel: i18n('icu:save'), + }); +} + +// Reducer + +export function getEmptyState(): BackupsStateType { + return { + workflow: undefined, + }; +} + +export function reducer( + state: BackupsStateType = getEmptyState(), + action: BackupsActionTGype +): BackupsStateType { + 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; + if ( + existingStep && + newStep && + existingType === 'plaintext-export' && + newType === 'plaintext-export' + ) { + if (!validTransitions[existingStep].has(newStep)) { + log.error( + `backups/SET_WORKFLOW: Invalid transition ${existingStep} to ${newStep}` + ); + return state; + } + } + + return { + ...state, + workflow: action.payload, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.preload.ts b/ts/state/getInitialState.preload.ts index 359fe5cf48..fe623900fc 100644 --- a/ts/state/getInitialState.preload.ts +++ b/ts/state/getInitialState.preload.ts @@ -5,6 +5,7 @@ import { getEmptyState as accountsEmptyState } from './ducks/accounts.preload.js import { getEmptyState as appEmptyState } from './ducks/app.preload.js'; import { getEmptyState as audioPlayerEmptyState } from './ducks/audioPlayer.preload.js'; import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder.preload.js'; +import { getEmptyState as backupsEmptyState } from './ducks/backups.preload.js'; import { getEmptyState as badgesEmptyState } from './ducks/badges.preload.js'; import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.preload.js'; import { getEmptyState as callingEmptyState } from './ducks/calling.preload.js'; @@ -149,6 +150,7 @@ function getEmptyState(): StateType { app: appEmptyState(), audioPlayer: audioPlayerEmptyState(), audioRecorder: audioRecorderEmptyState(), + backups: backupsEmptyState(), badges: badgesEmptyState(), callHistory: callHistoryEmptyState(), calling: callingEmptyState(), diff --git a/ts/state/initializeRedux.preload.ts b/ts/state/initializeRedux.preload.ts index 8162c67e46..5d708f3020 100644 --- a/ts/state/initializeRedux.preload.ts +++ b/ts/state/initializeRedux.preload.ts @@ -56,6 +56,7 @@ export function initializeRedux(data: ReduxInitData): void { actionCreators.audioRecorder, store.dispatch ), + backups: bindActionCreators(actionCreators.backups, store.dispatch), badges: bindActionCreators(actionCreators.badges, store.dispatch), callHistory: bindActionCreators(actionCreators.callHistory, store.dispatch), calling: bindActionCreators(actionCreators.calling, store.dispatch), diff --git a/ts/state/reducer.preload.ts b/ts/state/reducer.preload.ts index d4077813d1..9d88818a33 100644 --- a/ts/state/reducer.preload.ts +++ b/ts/state/reducer.preload.ts @@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts.preload.js'; import { reducer as app } from './ducks/app.preload.js'; import { reducer as audioPlayer } from './ducks/audioPlayer.preload.js'; import { reducer as audioRecorder } from './ducks/audioRecorder.preload.js'; +import { reducer as backups } from './ducks/backups.preload.js'; import { reducer as badges } from './ducks/badges.preload.js'; import { reducer as calling } from './ducks/calling.preload.js'; import { reducer as callHistory } from './ducks/callHistory.preload.js'; @@ -44,6 +45,7 @@ export const reducer = combineReducers({ app, audioPlayer, audioRecorder, + backups, badges, calling, callHistory, diff --git a/ts/state/selectors/backups.std.ts b/ts/state/selectors/backups.std.ts new file mode 100644 index 0000000000..91847d5693 --- /dev/null +++ b/ts/state/selectors/backups.std.ts @@ -0,0 +1,41 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +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'; + +export const getBackups = (state: StateType): BackupsStateType => state.backups; + +export const shouldShowPlaintextWorkflow = createSelector( + getBackups, + (backups: BackupsStateType): boolean => { + const workflow = backups.workflow?.workflow; + const isPlaintextExport = backups.workflow?.type === 'plaintext-export'; + + if (!isPlaintextExport || !workflow) { + return false; + } + + if ( + (workflow.step === PlaintextExportSteps.ExportingAttachments || + workflow.step === PlaintextExportSteps.ExportingMessages) && + workflow.exportInBackground === true + ) { + return false; + } + + return true; + } +); + +export const getWorkflow = createSelector( + getBackups, + (backups: BackupsStateType): PlaintextExportWorkflowType | undefined => { + return backups.workflow?.workflow; + } +); diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx index a106c0465d..32ebd76f7a 100644 --- a/ts/state/smart/GlobalModalContainer.preload.tsx +++ b/ts/state/smart/GlobalModalContainer.preload.tsx @@ -32,6 +32,8 @@ import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipa import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.js'; 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'; function renderCallLinkAddNameModal(): JSX.Element { return ; @@ -89,6 +91,10 @@ function renderNotePreviewModal(): JSX.Element { return ; } +function renderPlaintextExportWorkflow(): JSX.Element { + return ; +} + function renderStoriesSettings(): JSX.Element { return ; } @@ -110,6 +116,9 @@ export const SmartGlobalModalContainer = memo( const conversationsStoppingSend = useSelector(getConversationsStoppingSend); const i18n = useSelector(getIntl); const theme = useSelector(getTheme); + const shouldShowPlaintextExportWorkflow = useSelector( + shouldShowPlaintextWorkflow + ); const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0; @@ -282,6 +291,7 @@ export const SmartGlobalModalContainer = memo( renderMessageRequestActionsConfirmation } renderNotePreviewModal={renderNotePreviewModal} + renderPlaintextExportWorkflow={renderPlaintextExportWorkflow} renderProfileNameWarningModal={renderProfileNameWarningModal} renderUsernameOnboarding={renderUsernameOnboarding} renderSafetyNumber={renderSafetyNumber} @@ -291,6 +301,7 @@ export const SmartGlobalModalContainer = memo( renderStoriesSettings={renderStoriesSettings} safetyNumberChangedBlockingData={safetyNumberChangedBlockingData} safetyNumberModalContactId={safetyNumberModalContactId} + shouldShowPlaintextExportWorkflow={shouldShowPlaintextExportWorkflow} stickerPackPreviewId={stickerPackPreviewId} tapToViewNotAvailableModalProps={tapToViewNotAvailableModalProps} theme={theme} diff --git a/ts/state/smart/PlaintextExportWorkflow.preload.tsx b/ts/state/smart/PlaintextExportWorkflow.preload.tsx new file mode 100644 index 0000000000..9ffe9a2b26 --- /dev/null +++ b/ts/state/smart/PlaintextExportWorkflow.preload.tsx @@ -0,0 +1,61 @@ +// 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, + getWorkflow, + shouldShowPlaintextWorkflow, +} from '../selectors/backups.std.js'; +import { useBackupActions } from '../ducks/backups.preload.js'; +import { PlaintextExportWorkflow } from '../../components/PlaintextExportWorkflow.dom.js'; +import { useToastActions } from '../ducks/toast.preload.js'; + +const log = createLogger('smart/PlaintextExportWorkflow'); + +export const SmartPlaintextExportWorkflow = memo( + function SmartPlaintextExportWorkflow() { + const backups = useSelector(getBackups); + const workflow = useSelector(getWorkflow); + const shouldWeRender = useSelector(shouldShowPlaintextWorkflow); + const { osName } = useSelector(getUser); + + const i18n = useSelector(getIntl); + + const { openFileInFolder } = useToastActions(); + const { cancelWorkflow, clearWorkflow, verifyWithOSForExport } = + useBackupActions(); + + const containerType = backups.workflow?.type; + if (containerType !== 'plaintext-export') { + log.error( + `SmartPlaintextExportWorkflow: containerType is ${containerType}!` + ); + return; + } + if (!shouldWeRender) { + log.error('SmartPlaintextExportWorkflow: shouldWeRender=false!'); + return; + } + if (!workflow) { + log.error('SmartPlaintextExportWorkflow: no workflow!'); + return; + } + + return ( + + ); + } +); diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index 15d96b3891..85d5c6757f 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -73,7 +73,6 @@ import { import { getPreferredBadgeSelector } from '../selectors/badges.preload.js'; import { SmartProfileEditor } from './ProfileEditor.preload.js'; import { useNavActions } from '../ducks/nav.std.js'; -import type { SettingsLocation } from '../../types/Nav.std.js'; import { NavTab } from '../../types/Nav.std.js'; import { SmartToastManager } from './ToastManager.preload.js'; import { useToastActions } from '../ducks/toast.preload.js'; @@ -83,7 +82,24 @@ import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.dom.js'; import { SmartPreferencesDonations } from './PreferencesDonations.preload.js'; import { useDonationsActions } from '../ducks/donations.preload.js'; import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt.dom.js'; +import { getProfiles } from '../selectors/notificationProfiles.dom.js'; +import { backupLevelFromNumber } from '../../services/backups/types.std.js'; +import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.js'; +import { useBackupActions } from '../ducks/backups.preload.js'; +import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.js'; +import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.js'; +import { AxoProvider } from '../../axo/AxoProvider.dom.js'; +import { + getCurrentChatFoldersCount, + getHasAnyCurrentCustomChatFolders, +} from '../selectors/chatFolders.std.js'; +import { + SmartNotificationProfilesCreateFlow, + SmartNotificationProfilesHome, +} from './PreferencesNotificationProfiles.preload.js'; +import type { SettingsLocation } from '../../types/Nav.std.js'; import type { StorageAccessType, ZoomFactorType, @@ -100,22 +116,8 @@ import { } from '../../util/backupMediaDownload.preload.js'; import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary.dom.js'; import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.js'; -import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.js'; import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.preload.js'; -import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.js'; -import { AxoProvider } from '../../axo/AxoProvider.dom.js'; -import { - getCurrentChatFoldersCount, - getHasAnyCurrentCustomChatFolders, -} from '../selectors/chatFolders.std.js'; -import { - SmartNotificationProfilesCreateFlow, - SmartNotificationProfilesHome, -} from './PreferencesNotificationProfiles.preload.js'; import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.preload.js'; -import { getProfiles } from '../selectors/notificationProfiles.dom.js'; -import { backupLevelFromNumber } from '../../services/backups/types.std.js'; -import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.js'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -226,6 +228,7 @@ export function SmartPreferences(): JSX.Element | null { const { changeLocation } = useNavActions(); const { showToast } = useToastActions(); const { internalAddDonationReceipt } = useDonationsActions(); + const { startPlaintextExport } = useBackupActions(); // Selectors @@ -585,6 +588,13 @@ export function SmartPreferences(): JSX.Element | null { const backupLocalBackupsEnabled = isLocalBackupsEnabled(items.remoteConfig); const backupFreeMediaDays = getMessageQueueTime(items.remoteConfig) / DAY; + const isPlaintextExportEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.plaintextExport.beta', + currentVersion: version, + remoteConfig: items.remoteConfig, + prodKey: 'desktop.plaintextExport.prod', + }); + // Two-way items function createItemsAccess( @@ -848,6 +858,7 @@ export function SmartPreferences(): JSX.Element | null { isMinimizeToAndStartInSystemTraySupported } isNotificationAttentionSupported={isNotificationAttentionSupported} + isPlaintextExportEnabled={isPlaintextExportEnabled} isSyncSupported={isSyncSupported} isSystemTraySupported={isSystemTraySupported} isInternalUser={isInternalUser} @@ -937,6 +948,7 @@ export function SmartPreferences(): JSX.Element | null { setSettingsLocation={setSettingsLocation} shouldShowUpdateDialog={shouldShowUpdateDialog} showToast={showToast} + startPlaintextExport={startPlaintextExport} theme={theme} themeSetting={themeSetting} universalExpireTimer={universalExpireTimer} diff --git a/ts/state/types.std.ts b/ts/state/types.std.ts index 10d6b43d8a..be317364be 100644 --- a/ts/state/types.std.ts +++ b/ts/state/types.std.ts @@ -9,6 +9,7 @@ import type { actions as accounts } from './ducks/accounts.preload.js'; import type { actions as app } from './ducks/app.preload.js'; import type { actions as audioPlayer } from './ducks/audioPlayer.preload.js'; import type { actions as audioRecorder } from './ducks/audioRecorder.preload.js'; +import type { actions as backups } from './ducks/backups.preload.js'; import type { actions as badges } from './ducks/badges.preload.js'; import type { actions as callHistory } from './ducks/callHistory.preload.js'; import type { actions as calling } from './ducks/calling.preload.js'; @@ -45,6 +46,7 @@ export type ReduxActions = { app: typeof app; audioPlayer: typeof audioPlayer; audioRecorder: typeof audioRecorder; + backups: typeof backups; badges: typeof badges; callHistory: typeof callHistory; calling: typeof calling; diff --git a/ts/test-electron/backup/filePointer_test.preload.ts b/ts/test-electron/backup/filePointer_test.preload.ts index 22dcab61ae..6180b4280e 100644 --- a/ts/test-electron/backup/filePointer_test.preload.ts +++ b/ts/test-electron/backup/filePointer_test.preload.ts @@ -477,8 +477,13 @@ describe('getFilePointerForAttachment', () => { }), }), backupJob: { + isPlaintextExport: false, data: { + contentType: defaultAttachment.contentType, + fileName: defaultAttachment.fileName, + localKey: defaultAttachment.localKey, path: defaultAttachment.path, + size: defaultAttachment.size, }, mediaName: defaultLocalMediaName, type: 'local', diff --git a/ts/test-electron/services/AttachmentBackupManager_test.preload.ts b/ts/test-electron/services/AttachmentBackupManager_test.preload.ts index e9623416f4..944fe0f00c 100644 --- a/ts/test-electron/services/AttachmentBackupManager_test.preload.ts +++ b/ts/test-electron/services/AttachmentBackupManager_test.preload.ts @@ -237,7 +237,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager( // Confirm they are saved to DB const allJobs = await getAllSavedJobs(); - assert.strictEqual(allJobs.length, 10); + assert.strictEqual(allJobs.length, 10, 'initial setup'); await backupManager?.start(); await waitForJobToBeStarted(jobs[2]); @@ -246,11 +246,11 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager( assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]); await waitForJobToBeStarted(jobs[0]); - assert.strictEqual(runJob.callCount, 5); + assert.strictEqual(runJob.callCount, 5, 'first calls'); assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]); await waitForJobToBeCompleted(thumbnailJobs[0]); - assert.strictEqual(runJob.callCount, 10); + assert.strictEqual(runJob.callCount, 10, 'all calls'); assertRunJobCalledWith([ jobs[4], diff --git a/ts/test-electron/util/isFeatureEnabled_test.dom.ts b/ts/test-electron/util/isFeatureEnabled_test.dom.ts new file mode 100644 index 0000000000..c9c56a5783 --- /dev/null +++ b/ts/test-electron/util/isFeatureEnabled_test.dom.ts @@ -0,0 +1,187 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { _isFeatureEnabledInner } from '../../util/isFeatureEnabled.dom.js'; + +const isTestEnvironment = () => false; + +describe('isFeatureEnabled', () => { + it('returns false if nothing triggers', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if current version is invalid semver', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'broken', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if internal', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.1.0', + isInternalUser: true, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if isAlpha', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0-alpha.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if isStaging', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0-staging.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if isTestEnvironment', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0', + isInternalUser: false, + isTestEnvironment: () => true, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if beta and beta version is greater', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v2.0.0-beta.2', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if beta and beta version is equal', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v2.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if beta and beta version is lesser', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if beta and no beta value', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: undefined, + currentVersion: 'v1.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if beta and beta value is not valid semver', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'broken', + currentVersion: 'v1.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + + it('returns true if prod and prod version is greater', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v2.0.0', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns true if prod and prod version is equal', async () => { + assert.isTrue( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.1.0', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if prod and prod version is lesser', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0', + isInternalUser: false, + isTestEnvironment, + prodValue: 'v1.1.0', + }) + ); + }); + it('returns false if prod and no prod value', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: undefined, + }) + ); + }); + it('returns false if prod and prod value is not valid semver', async () => { + assert.isFalse( + _isFeatureEnabledInner({ + betaValue: 'v2.0.0-beta.1', + currentVersion: 'v1.0.0-beta.1', + isInternalUser: false, + isTestEnvironment, + prodValue: 'broken', + }) + ); + }); +}); diff --git a/ts/types/AttachmentBackup.std.ts b/ts/types/AttachmentBackup.std.ts index 65de176513..b49282c29c 100644 --- a/ts/types/AttachmentBackup.std.ts +++ b/ts/types/AttachmentBackup.std.ts @@ -45,21 +45,17 @@ export type ThumbnailAttachmentBackupJobType = { export type CoreAttachmentLocalBackupJobType = { type: 'local'; + isPlaintextExport: boolean; mediaName: string; data: { + contentType: MIMEType; + fileName: string | undefined; + localKey: string; path: string | null; + size: number; }; - backupsBaseDir: string; }; -export type PartialAttachmentLocalBackupJobType = Omit< - CoreAttachmentLocalBackupJobType, - 'backupsBaseDir' ->; - -export type AttachmentLocalBackupJobType = CoreAttachmentLocalBackupJobType & - JobManagerJobType; - const standardBackupJobDataSchema = z.object({ type: z.literal('standard'), mediaName: z.string(), diff --git a/ts/types/Backups.std.ts b/ts/types/Backups.std.ts new file mode 100644 index 0000000000..49314d38f3 --- /dev/null +++ b/ts/types/Backups.std.ts @@ -0,0 +1,126 @@ +// Copyright 2025 Signal Messenger, LLC +// 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 { + 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'); + } +} +export class RanOutOfStorageError extends Error { + constructor(public readonly bytesNeeded: number) { + super('RanOutOfStorageError'); + } +} +export class StoragePermissionsError extends Error { + constructor() { + super('StoragePermissionsError'); + } +} + +export type PlaintextExportWorkflowType = + | { + step: PlaintextExportSteps.ConfirmingExport; + } + | { + step: PlaintextExportSteps.ConfirmingWithOS; + includeMedia: boolean; + } + | { + step: PlaintextExportSteps.ChoosingLocation; + includeMedia: boolean; + } + | { + step: PlaintextExportSteps.ExportingMessages; + abortController: AbortController; + exportInBackground: boolean; + exportPath: string; + } + | { + // We automatically transition from ExportingMessages to ExportingAttachments when + // our onProgress callback is first called. + step: PlaintextExportSteps.ExportingAttachments; + abortController: AbortController; + exportInBackground: boolean; + progress: ExportProgress; + exportPath: string; + } + | { + step: PlaintextExportSteps.Complete; + exportPath: string; + } + | { + // Not a normal step: Something went wrong, and we need to show error to the user + step: PlaintextExportSteps.Error; + errorDetails: PlaintextExportErrorDetails; + }; + +// We can cancel in all states, but only need Canceling when we were actively exporting +export const validTransitions: { + [key in PlaintextExportSteps]: Set; +} = { + [PlaintextExportSteps.ConfirmingExport]: new Set([ + PlaintextExportSteps.ConfirmingWithOS, + ]), + [PlaintextExportSteps.ConfirmingWithOS]: new Set([ + PlaintextExportSteps.ChoosingLocation, + ]), + [PlaintextExportSteps.ChoosingLocation]: new Set([ + PlaintextExportSteps.ExportingMessages, + ]), + [PlaintextExportSteps.ExportingMessages]: new Set([ + PlaintextExportSteps.Complete, + PlaintextExportSteps.Error, + PlaintextExportSteps.ExportingAttachments, + ]), + [PlaintextExportSteps.ExportingAttachments]: new Set([ + // When updating progress, we transition to the same step, new progress + PlaintextExportSteps.ExportingAttachments, + PlaintextExportSteps.Complete, + PlaintextExportSteps.Error, + ]), + // All three of these are terminal states + [PlaintextExportSteps.Complete]: new Set([]), + [PlaintextExportSteps.Error]: new Set([]), +}; diff --git a/ts/types/RendererConfig.std.ts b/ts/types/RendererConfig.std.ts index 5cd7eb2b54..3e3c30479f 100644 --- a/ts/types/RendererConfig.std.ts +++ b/ts/types/RendererConfig.std.ts @@ -45,6 +45,7 @@ export const rendererConfigSchema = z.object({ disableIPv6: z.boolean(), disableScreenSecurity: z.boolean(), dnsFallback: DNSFallbackSchema, + downloadsPath: configRequiredStringSchema, environment: environmentSchema, isMockTestEnvironment: z.boolean(), homePath: configRequiredStringSchema, diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 9061ffd1d8..a80e3cdd3c 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -246,6 +246,17 @@ export type StorageAccessType = { // Stored solely for pesistance during import/export sequence svrPin: string; optimizeOnDeviceStorage: boolean; + pinReminders: boolean | undefined; + screenLockTimeoutMinutes: number | undefined; + 'auto-download-attachment-primary': + | undefined + | { + photos: number; + audio: number; + videos: number; + documents: number; + }; + androidSpecificSettings: unknown; postRegistrationSyncsStatus: 'incomplete' | 'complete'; diff --git a/ts/util/getFreeDiskSpace.node.ts b/ts/util/getFreeDiskSpace.node.ts new file mode 100644 index 0000000000..c43fef4838 --- /dev/null +++ b/ts/util/getFreeDiskSpace.node.ts @@ -0,0 +1,9 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { statfs } from 'node:fs/promises'; + +export async function getFreeDiskSpace(target: string): Promise { + const { bsize, bavail } = await statfs(target); + return bsize * bavail; +} diff --git a/ts/util/isFeatureEnabled.dom.ts b/ts/util/isFeatureEnabled.dom.ts new file mode 100644 index 0000000000..ccebde59ce --- /dev/null +++ b/ts/util/isFeatureEnabled.dom.ts @@ -0,0 +1,98 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import semver from 'semver'; + +import type { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep.js'; + +import { createLogger } from '../logging/log.std.js'; +import { isTestOrMockEnvironment } from '../environment.std.js'; +import { getValue, isEnabled } from '../RemoteConfig.dom.js'; +import { isAlpha, isBeta, isProduction, isStaging } from './version.std.js'; + +import type { SemverKeyType, ConfigMapType } from '../RemoteConfig.dom.js'; + +const log = createLogger('isFeatureEnabled'); + +export function isFeaturedEnabledSelector({ + betaKey, + currentVersion, + prodKey, + remoteConfig, +}: { + betaKey: SemverKeyType; + currentVersion: string; + prodKey: SemverKeyType; + remoteConfig: ReadonlyObjectDeep | undefined; +}): boolean { + return _isFeatureEnabledInner({ + betaValue: remoteConfig?.[betaKey]?.value, + currentVersion, + isInternalUser: remoteConfig?.['desktop.internalUser']?.enabled ?? false, + prodValue: remoteConfig?.[prodKey]?.value, + }); +} + +export function isFeaturedEnabledNoRedux({ + betaKey, + prodKey, +}: { + betaKey: SemverKeyType; + prodKey: SemverKeyType; +}): boolean { + return _isFeatureEnabledInner({ + betaValue: getValue(betaKey), + currentVersion: window.getVersion(), + isInternalUser: isEnabled('desktop.internalUser'), + prodValue: getValue(prodKey), + }); +} + +// Exported for testing +export function _isFeatureEnabledInner({ + betaValue, + currentVersion, + isInternalUser, + prodValue, + isTestEnvironment = isTestOrMockEnvironment, +}: { + betaValue: string | undefined; + currentVersion: string; + isInternalUser: boolean; + prodValue: string | undefined; + isTestEnvironment?: () => boolean; +}): boolean { + if ( + isInternalUser || + isAlpha(currentVersion) || + isStaging(currentVersion) || + isTestEnvironment() + ) { + return true; + } + + if (!semver.parse(currentVersion)) { + log.error(`currentVersion ${currentVersion} was invalid`); + return false; + } + + if ( + isBeta(currentVersion) && + betaValue && + semver.parse(betaValue) && + semver.gte(currentVersion, betaValue) + ) { + return true; + } + + if ( + isProduction(currentVersion) && + prodValue && + semver.parse(prodValue) && + semver.gte(currentVersion, prodValue) + ) { + return true; + } + + return false; +} diff --git a/ts/util/os/promptOSAuthMain.main.ts b/ts/util/os/promptOSAuthMain.main.ts index a1f3f3c0ab..382dfc7ebc 100644 --- a/ts/util/os/promptOSAuthMain.main.ts +++ b/ts/util/os/promptOSAuthMain.main.ts @@ -14,7 +14,10 @@ import { missingCaseError } from '../missingCaseError.std.js'; const log = createLogger('promptOSAuthMain'); -export type PromptOSAuthReasonType = 'enable-backups' | 'view-aep'; +export type PromptOSAuthReasonType = + | 'enable-backups' + | 'view-aep' + | 'plaintext-export'; export type PromptOSAuthResultType = | 'error' @@ -101,6 +104,9 @@ async function promptOSAuthLinux( 'pkcheck -u --process $$ --action-id org.signalapp.enable-backups'; } else if (reason === 'view-aep') { command = 'pkcheck -u --process $$ --action-id org.signalapp.view-aep'; + } else if (reason === 'plaintext-export') { + command = + 'pkcheck -u --process $$ --action-id org.signalapp.plaintext-export'; } else { throw missingCaseError(reason); } @@ -112,6 +118,7 @@ async function promptOSAuthLinux( } else if (code === 3) { resolve('unauthorized'); } else { + log.warn(`promptOSAuthLinux: Got code ${code} from call to pkcheck`); resolve('error'); } }); diff --git a/ts/util/promptOSAuth.preload.ts b/ts/util/promptOSAuth.preload.ts index 884797708e..97f69d48b8 100644 --- a/ts/util/promptOSAuth.preload.ts +++ b/ts/util/promptOSAuth.preload.ts @@ -6,6 +6,7 @@ import type { PromptOSAuthReasonType, PromptOSAuthResultType, } from './os/promptOSAuthMain.main.js'; +import { missingCaseError } from './missingCaseError.std.js'; export async function promptOSAuth( reason: PromptOSAuthReasonType @@ -16,23 +17,46 @@ export async function promptOSAuth( // TODO: DESKTOP-8895 if (window.Signal.OS.isMacOS()) { if (reason === 'enable-backups') { - localeString = 'enable backups'; + localeString = window.SignalContext.i18n( + 'icu:Preferences__local-backups--enable--os-prompt--mac' + ); + } else if (reason === 'plaintext-export') { + localeString = window.SignalContext.i18n( + 'icu:PlaintextExport--OSPrompt--Mac' + ); } else if (reason === 'view-aep') { - localeString = 'show your backup key'; + localeString = window.SignalContext.i18n( + 'icu:Preferences--local-backups--view-backup-key--os-prompt--mac' + ); + } else { + throw missingCaseError(reason); } } if (window.Signal.OS.isWindows()) { if (reason === 'enable-backups') { - localeString = 'Verify your identity to enable backups.'; + localeString = window.SignalContext.i18n( + 'icu:Preferences__local-backups--enable--os-prompt--windows' + ); + } else if (reason === 'plaintext-export') { + localeString = window.SignalContext.i18n( + 'icu:PlaintextExport--OSPrompt--Windows' + ); } else if (reason === 'view-aep') { - localeString = 'Verify your identity to view your backup key.'; + localeString = window.SignalContext.i18n( + 'icu:Preferences--local-backups--view-backup-key--os-prompt--windows' + ); + } else { + throw missingCaseError(reason); } } ipcRenderer.once(`prompt-os-auth:${reason}`, (_, response) => { resolve(response ?? 'error'); }); - ipcRenderer.send('prompt-os-auth', { reason, localeString }); + ipcRenderer.send('prompt-os-auth', { + reason, + localeString, + }); }); } diff --git a/ts/util/timestamp.std.ts b/ts/util/timestamp.std.ts index ddb781baa3..6730eac9f3 100644 --- a/ts/util/timestamp.std.ts +++ b/ts/util/timestamp.std.ts @@ -42,3 +42,15 @@ export const MIN_SAFE_DATE = -8640000000000000; export function toBoundedDate(timestamp: number): Date { return new Date(Math.max(MIN_SAFE_DATE, Math.min(timestamp, MAX_SAFE_DATE))); } + +export function addLeadingZero(amount: number): string { + if (amount < 10) { + return `0${amount}`; + } + return amount.toString(); +} + +export function getTimestampForFolder(): string { + const date = new Date(); + return `${date.getFullYear()}-${addLeadingZero(date.getMonth() + 1)}-${addLeadingZero(date.getDate())}-${addLeadingZero(date.getHours())}-${addLeadingZero(date.getMinutes())}-${addLeadingZero(date.getSeconds())}`; +}