Support for exporting chats to disk

This commit is contained in:
Scott Nonnenberg
2025-11-19 02:12:04 +10:00
committed by GitHub
parent 6b16d75036
commit c4378d9c24
53 changed files with 2549 additions and 366 deletions

View File

@@ -234,7 +234,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with: with:
repository: 'signalapp/Signal-Message-Backup-Tests' repository: 'signalapp/Signal-Message-Backup-Tests'
ref: '455fbe5854bd3be5002f17ae929a898c0975adc4' ref: '551f9ad1186d196e8698df4a5750b239f0796a70'
path: 'backup-integration-tests' path: 'backup-integration-tests'
- run: xvfb-run --auto-servernum pnpm run test-electron - run: xvfb-run --auto-servernum pnpm run test-electron

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules_bkp
coverage/* coverage/*
build/curve25519_compiled.js build/curve25519_compiled.js
build/compact-locales build/compact-locales
build/*.policy
stylesheets/*.css.map stylesheets/*.css.map
/dist /dist
.DS_Store .DS_Store

View File

@@ -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 Copyright (c) 2015 The Rust Project Developers

View File

@@ -7200,6 +7200,118 @@
"messageformat": "Notification content", "messageformat": "Notification content",
"description": "Label for the notification content setting select box" "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": "<bold>BE CAREFUL!</bold> 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 computers permissions.",
"description": "Description of confirmation dialog shown before the export starts"
},
"icu:PlaintextExport--Confirmation--IncludeMedia": {
"messageformat": "Include media <secondary>(larger file size)</secondary>",
"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": "<bold>BE CAREFUL</bold> 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 computers 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": "Couldnt 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 cant be exported because your computer doesnt 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": "Couldnt 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 couldnt be exported because your computer doesnt 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": "Cant 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 cant be exported because Signal doesnt have permission to write files to disk. Try changing your disk's permissions or updating your system settings and then export again.",
"description": "Detail text in dialog shown when we attempted to save a file during export but got a permissions error"
},
"icu:NotificationProfile--moon-icon": { "icu:NotificationProfile--moon-icon": {
"messageformat": "Moon icon", "messageformat": "Moon icon",
"description": "Screenreader description for the moon icon used to signify notification profiles" "description": "Screenreader description for the moon icon used to signify notification profiles"
@@ -7632,6 +7744,22 @@
"messageformat": "Done", "messageformat": "Done",
"description": "Button to dismiss the backup key viewer, shown when reviewing your backup key for local on-device backups." "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": { "icu:Preferences--local-backups-backup-key-text-box": {
"messageformat": "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." "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", "messageformat": "Backup key copied",
"description": "Toast message after you copied the backup key to clipboard from settings for local on-device backups" "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": { "icu:Preferences__view-key": {
"messageformat": "View key", "messageformat": "View key",
"description": "Button to view the backup key which is used to restore a message history backup" "description": "Button to view the backup key which is used to restore a message history backup"

View File

@@ -2807,6 +2807,7 @@ ipc.on('get-config', async event => {
// paths // paths
crashDumpsPath: app.getPath('crashDumps'), crashDumpsPath: app.getPath('crashDumps'),
downloadsPath: app.getPath('downloads'),
homePath: app.getPath('home'), homePath: app.getPath('home'),
installPath: app.getAppPath(), installPath: app.getAppPath(),
userDataPath: app.getPath('userData'), userDataPath: app.getPath('userData'),

View File

@@ -4,12 +4,12 @@
<vendor>Signal Desktop</vendor> <vendor>Signal Desktop</vendor>
<vendor_url>https://signal.org/</vendor_url> <vendor_url>https://signal.org/</vendor_url>
<action id="org.signalapp.enable-backups"> <action id="org.signalapp.enable-backups">
<description>Enable backups</description> <!-- <description>{description}</description> -->
<message>Authentication is required to enable backups.</message> <!-- <message>{message}</message> -->
<defaults> <defaults>
<allow_any>auth_admin</allow_any> <allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive> <allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active> <allow_active>auth_admin</allow_active>
</defaults> </defaults>
</action> </action>
</policyconfig> </policyconfig>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD polkit Policy Configuration 1.0//EN" "http://www.freedesktop.org/software/polkit/policyconfig-1.dtd">
<policyconfig>
<vendor>Signal Desktop</vendor>
<vendor_url>https://signal.org/</vendor_url>
<action id="org.signalapp.plaintext-export">
<!-- <description>{description}</description> -->
<!-- <message>{message}</message> -->
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
</action>
</policyconfig>

View File

@@ -4,12 +4,12 @@
<vendor>Signal Desktop</vendor> <vendor>Signal Desktop</vendor>
<vendor_url>https://signal.org/</vendor_url> <vendor_url>https://signal.org/</vendor_url>
<action id="org.signalapp.view-aep"> <action id="org.signalapp.view-aep">
<description>View backup key</description> <!-- <description>{description}</description> -->
<message>Authentication is required to view your backup key.</message> <!-- <message>{message}</message> -->
<defaults> <defaults>
<allow_any>auth_admin</allow_any> <allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive> <allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active> <allow_active>auth_admin</allow_active>
</defaults> </defaults>
</action> </action>
</policyconfig> </policyconfig>

View File

@@ -20,7 +20,7 @@
"start": "electron .", "start": "electron .",
"generate": "run-s generate:phase-0 generate:phase-1", "generate": "run-s generate:phase-0 generate:phase-1",
"generate:phase-0": "run-p build:esbuild:scripts", "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", "generate:phase-1:bundle": "run-s build-protobuf build:esbuild:bundle",
"build-release": "pnpm run build", "build-release": "pnpm run build",
"sign-release": "node ts/updater/generateSignature.js", "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", "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": "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-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:acknowledgments": "node scripts/generate-acknowledgments.js",
"build:dns-fallback": "node ts/scripts/generate-dns-fallback.node.js", "build:dns-fallback": "node ts/scripts/generate-dns-fallback.node.js",
"build:icu-types": "node ts/scripts/generate-icu-types.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:scripts": "node scripts/esbuild.js --no-bundle",
"build:esbuild:bundle": "node scripts/esbuild.js --no-scripts", "build:esbuild:bundle": "node scripts/esbuild.js --no-scripts",
"build:esbuild:prod": "node scripts/esbuild.js --prod", "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": "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: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", "build:styles:tailwind": "tailwindcss -i ./stylesheets/tailwind-config.css -o ./stylesheets/tailwind.css",
@@ -132,7 +133,7 @@
"@react-aria/utils": "3.25.3", "@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@react-types/shared": "3.27.0", "@react-types/shared": "3.27.0",
"@signalapp/libsignal-client": "0.83.0", "@signalapp/libsignal-client": "0.86.3",
"@signalapp/minimask": "1.0.1", "@signalapp/minimask": "1.0.1",
"@signalapp/mute-state-change": "workspace:1.0.0", "@signalapp/mute-state-change": "workspace:1.0.0",
"@signalapp/quill-cjs": "2.1.2", "@signalapp/quill-cjs": "2.1.2",

View File

@@ -11,10 +11,10 @@ index 47e6f48fcbed88b6ac07cff15c888c1b8b59721f..76dd6cc7265054222f2d70c76aa8456d
productFilename: packager.appInfo.productFilename, productFilename: packager.appInfo.productFilename,
...packager.platformSpecificBuildOptions, ...packager.platformSpecificBuildOptions,
diff --git a/templates/linux/after-install.tpl b/templates/linux/after-install.tpl 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 --- a/templates/linux/after-install.tpl
+++ b/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" echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile"
fi fi
fi fi
@@ -27,10 +27,12 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83
+ POLICY_ORG='org.signalapp' + POLICY_ORG='org.signalapp'
+ POLICY_ENABLE_BACKUPS='enable-backups.policy' + POLICY_ENABLE_BACKUPS='enable-backups.policy'
+ POLICY_VIEW_AEP='view-aep.policy' + POLICY_VIEW_AEP='view-aep.policy'
+ POLICY_EXPORT='plaintext-export.policy'
+ mkdir -p "$POLICY_TARGET_PATH"; + mkdir -p "$POLICY_TARGET_PATH";
+ # Separate policies for staging and production builds + # 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_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_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 +else
+ echo "Skipping installation of policies as polkit does not seem to be installed. This may affect the availability of some features."; + echo "Skipping installation of policies as polkit does not seem to be installed. This may affect the availability of some features.";
+fi +fi
@@ -40,7 +42,7 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83
+ +
+# SIGNAL CHANGES END +# SIGNAL CHANGES END
diff --git a/templates/linux/after-remove.tpl b/templates/linux/after-remove.tpl 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 --- a/templates/linux/after-remove.tpl
+++ b/templates/linux/after-remove.tpl +++ b/templates/linux/after-remove.tpl
@@ -13,3 +13,12 @@ APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' @@ -13,3 +13,12 @@ APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'
@@ -56,7 +58,6 @@ index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..b5011d1b8cdb741ba6453f942a3c0660
+fi +fi
+ +
+# SIGNAL CHANGES END +# SIGNAL CHANGES END
\ No newline at end of file
diff --git a/templates/nsis/include/installer.nsh b/templates/nsis/include/installer.nsh diff --git a/templates/nsis/include/installer.nsh b/templates/nsis/include/installer.nsh
index 34e91dfe82fdbb2e929820f2e8deb771b7f7893c..73bfffc6c227a018cbbeb690d6d7b882ed142fc8 100644 index 34e91dfe82fdbb2e929820f2e8deb771b7f7893c..73bfffc6c227a018cbbeb690d6d7b882ed142fc8 100644
--- a/templates/nsis/include/installer.nsh --- a/templates/nsis/include/installer.nsh

20
pnpm-lock.yaml generated
View File

@@ -29,7 +29,7 @@ patchedDependencies:
hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495 hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495
path: patches/@vitest+expect+2.0.5.patch path: patches/@vitest+expect+2.0.5.patch
app-builder-lib: app-builder-lib:
hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420 hash: a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664
path: patches/app-builder-lib.patch path: patches/app-builder-lib.patch
casual@1.6.2: casual@1.6.2:
hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599 hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599
@@ -124,8 +124,8 @@ importers:
specifier: 3.27.0 specifier: 3.27.0
version: 3.27.0(react@18.3.1) version: 3.27.0(react@18.3.1)
'@signalapp/libsignal-client': '@signalapp/libsignal-client':
specifier: 0.83.0 specifier: 0.86.3
version: 0.83.0 version: 0.86.3
'@signalapp/minimask': '@signalapp/minimask':
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
@@ -3487,8 +3487,8 @@ packages:
'@signalapp/libsignal-client@0.76.7': '@signalapp/libsignal-client@0.76.7':
resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==} resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==}
'@signalapp/libsignal-client@0.83.0': '@signalapp/libsignal-client@0.86.3':
resolution: {integrity: sha512-QaXviPAvj4PA2QDmN6YyPnlkp699BE3fIgaJmKrfvZMsvBfMGeJ3H3BHFt0CV2vUWMbc3oEgxbwdXu//f6oTrA==} resolution: {integrity: sha512-aN/pgT9YqacuABrtxBtBbQ0AMesZJIHVNqU8nUq75kRTleIU5aKeuOXt7ZHYUUJW7ot4O2n6O6eaMnMLbwBXFQ==}
'@signalapp/minimask@1.0.1': '@signalapp/minimask@1.0.1':
resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==}
@@ -14281,7 +14281,7 @@ snapshots:
type-fest: 4.26.1 type-fest: 4.26.1
uuid: 11.0.2 uuid: 11.0.2
'@signalapp/libsignal-client@0.83.0': '@signalapp/libsignal-client@0.86.3':
dependencies: dependencies:
node-gyp-build: 4.8.4 node-gyp-build: 4.8.4
type-fest: 4.26.1 type-fest: 4.26.1
@@ -15660,7 +15660,7 @@ snapshots:
app-builder-bin@5.0.0-alpha.12: {} 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: dependencies:
'@develar/schema-utils': 2.6.5 '@develar/schema-utils': 2.6.5
'@electron/asar': 3.4.1 '@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): dmg-builder@26.0.14(patch_hash=cb72ed47fa8d45513a36db33fcb41cb75c30cada4737da067bf3fa1f063725f2)(electron-builder-squirrel-windows@26.0.14):
dependencies: 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: 26.0.13
builder-util-runtime: 9.3.2 builder-util-runtime: 9.3.2
fs-extra: 10.1.0 fs-extra: 10.1.0
@@ -17025,7 +17025,7 @@ snapshots:
electron-builder-squirrel-windows@26.0.14(dmg-builder@26.0.14): electron-builder-squirrel-windows@26.0.14(dmg-builder@26.0.14):
dependencies: 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: 26.0.13
electron-winstaller: 5.4.0 electron-winstaller: 5.4.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -17035,7 +17035,7 @@ snapshots:
electron-builder@26.0.14(electron-builder-squirrel-windows@26.0.14): electron-builder@26.0.14(electron-builder-squirrel-windows@26.0.14):
dependencies: 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: 26.0.13
builder-util-runtime: 9.3.2 builder-util-runtime: 9.3.2
chalk: 4.1.2 chalk: 4.1.2

View File

@@ -5,6 +5,7 @@ syntax = "proto3";
package signalbackups; package signalbackups;
option java_package = "org.thoughtcrime.securesms.backup.v2.proto"; option java_package = "org.thoughtcrime.securesms.backup.v2.proto";
option swift_prefix = "BackupProto_";
message BackupInfo { message BackupInfo {
uint64 version = 1; 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. bytes mediaRootBackupKey = 3; // 32-byte random value generated when the backup is uploaded for the first time.
string currentAppVersion = 4; string currentAppVersion = 4;
string firstAppVersion = 5; string firstAppVersion = 5;
bytes debugInfo = 6; // Client-specific data field for debug info during testing
} }
// Frames must follow in the following ordering rules: // Frames must follow in the following ordering rules:
@@ -68,6 +70,26 @@ message AccountData {
Color color = 3; 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 { message AccountSettings {
bool readReceipts = 1; bool readReceipts = 1;
bool sealedSenderIndicators = 2; bool sealedSenderIndicators = 2;
@@ -91,6 +113,12 @@ message AccountData {
bool optimizeOnDeviceStorage = 20; bool optimizeOnDeviceStorage = 20;
// See zkgroup for integer particular values. Unset if backups are not enabled. // See zkgroup for integer particular values. Unset if backups are not enabled.
optional uint64 backupTier = 21; 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 { message SubscriberData {
@@ -111,6 +139,11 @@ message AccountData {
} }
} }
message AndroidSpecificSettings {
bool useSystemEmoji = 1;
bool screenshotSecurity = 2;
}
bytes profileKey = 1; bytes profileKey = 1;
optional string username = 2; optional string username = 2;
UsernameLink usernameLink = 3; UsernameLink usernameLink = 3;
@@ -122,6 +155,9 @@ message AccountData {
AccountSettings accountSettings = 9; AccountSettings accountSettings = 9;
IAPSubscriberData backupsSubscriberData = 10; IAPSubscriberData backupsSubscriberData = 10;
string svrPin = 11; string svrPin = 11;
AndroidSpecificSettings androidSpecificSettings = 12;
string bioText = 13;
string bioEmoji = 14;
} }
message Recipient { message Recipient {
@@ -344,7 +380,7 @@ message CallLink {
string name = 3; string name = 3;
Restrictions restrictions = 4; Restrictions restrictions = 4;
uint64 expirationMs = 5; uint64 expirationMs = 5;
optional bytes epoch = 6; optional bytes epoch = 6; // May be absent/empty for older links
} }
message AdHocCall { message AdHocCall {
@@ -695,7 +731,6 @@ message MessageAttachment {
} }
message FilePointer { message FilePointer {
message LocatorInfo { message LocatorInfo {
// Must be non-empty if transitCdnKey or plaintextHash are set/nonempty. // Must be non-empty if transitCdnKey or plaintextHash are set/nonempty.
// Otherwise must be empty. // Otherwise must be empty.
@@ -828,11 +863,6 @@ message Poll {
repeated Reaction reactions = 5; repeated Reaction reactions = 5;
} }
message PollTerminateUpdate {
uint64 targetSentTimestamp = 1;
string question = 2; // Between 1-100 characters
}
message ChatUpdateMessage { message ChatUpdateMessage {
// If unset, importers should ignore the update message without throwing an error. // If unset, importers should ignore the update message without throwing an error.
oneof update { oneof update {
@@ -1151,7 +1181,7 @@ message GroupJoinRequestCanceledUpdate {
bytes requestorAci = 1; 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. // their request repeatedly with no other updates in between.
// The last action encompassed by this update is always a // The last action encompassed by this update is always a
// cancellation; if there was another open request immediately // cancellation; if there was another open request immediately
@@ -1211,6 +1241,11 @@ message GroupExpirationTimerUpdate {
optional bytes updaterAci = 2; optional bytes updaterAci = 2;
} }
message PollTerminateUpdate {
uint64 targetSentTimestamp = 1;
string question = 2; // Between 1-100 characters
}
message StickerPack { message StickerPack {
bytes packId = 1; bytes packId = 1;
bytes packKey = 2; bytes packKey = 2;

View File

@@ -201,8 +201,13 @@ export function getCI({
} }
async function exportLocalBackup(backupsBaseDir: string): Promise<string> { async function exportLocalBackup(backupsBaseDir: string): Promise<string> {
const { snapshotDir } = const { snapshotDir } = await backupsService.exportLocalBackup(
await backupsService.exportLocalBackup(backupsBaseDir); backupsBaseDir,
{
type: 'local-encrypted',
localBackupSnapshotDir: backupsBaseDir,
}
);
return snapshotDir; return snapshotDir;
} }

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import lodash from 'lodash'; import lodash from 'lodash';
import semver from 'semver';
import type { getConfig } from './textsecure/WebAPI.preload.js'; import type { getConfig } from './textsecure/WebAPI.preload.js';
import { createLogger } from './logging/log.std.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 { getCountryCode } from './types/PhoneNumber.std.js';
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration.dom.js'; import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration.dom.js';
import type { StorageInterface } from './types/Storage.d.ts'; import type { StorageInterface } from './types/Storage.d.ts';
import { ToastType } from './types/Toast.dom.js';
const { get, throttle } = lodash; const { get, throttle } = lodash;
const log = createLogger('RemoteConfig'); 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.alpha',
'desktop.chatFolders.beta', 'desktop.chatFolders.beta',
'desktop.chatFolders.prod', 'desktop.chatFolders.prod',
@@ -56,6 +66,8 @@ const KnownConfigKeys = [
'global.textAttachmentLimitBytes', 'global.textAttachmentLimitBytes',
] as const; ] as const;
const KnownConfigKeys = [...SemverKeys, ...ScalarKeys] as const;
export type ConfigKeyType = (typeof KnownConfigKeys)[number]; export type ConfigKeyType = (typeof KnownConfigKeys)[number];
type ConfigValueType = { type ConfigValueType = {
@@ -139,6 +151,7 @@ export const _refreshRemoteConfig = async ({
} }
const oldConfig = config; const oldConfig = config;
let semverError = false;
config = Array.from(newConfigValues.entries()).reduce( config = Array.from(newConfigValues.entries()).reduce(
(acc, [name, value]) => { (acc, [name, value]) => {
const enabled = value !== undefined && value.toLowerCase() !== 'false'; const enabled = value !== undefined && value.toLowerCase() !== 'false';
@@ -169,6 +182,17 @@ export const _refreshRemoteConfig = async ({
const hasChanged = const hasChanged =
previouslyEnabled !== enabled || previousValue !== configValue.value; 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 // If enablement changes at all, notify listeners
const currentListeners = listeners[name] || []; const currentListeners = listeners[name] || [];
if (hasChanged) { 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'); const remoteExpirationValue = getValue('desktop.clientExpiration');
if (!remoteExpirationValue) { if (!remoteExpirationValue) {
// If remote configuration fetch worked - we are not expired anymore. // If remote configuration fetch worked - we are not expired anymore.

View File

@@ -215,7 +215,7 @@ export namespace AxoAlertDialog {
* ---------------------------------- * ----------------------------------
*/ */
export type ActionVariant = 'primary' | 'destructive'; export type ActionVariant = 'primary' | 'secondary' | 'destructive';
export type ActionProps = Readonly<{ export type ActionProps = Readonly<{
variant: ActionVariant; variant: ActionVariant;

View File

@@ -363,6 +363,7 @@ export namespace AxoDialog {
variant: ActionVariant; variant: ActionVariant;
symbol?: AxoSymbol.InlineGlyphName; symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean; arrow?: boolean;
experimentalSpinner?: { 'aria-label': string } | null;
onClick: () => void; onClick: () => void;
children: ReactNode; children: ReactNode;
}>; }>;
@@ -373,9 +374,10 @@ export namespace AxoDialog {
variant={props.variant} variant={props.variant}
symbol={props.symbol} symbol={props.symbol}
arrow={props.arrow} arrow={props.arrow}
experimentalSpinner={props.experimentalSpinner}
onClick={props.onClick}
size="md" size="md"
width="grow" width="grow"
onClick={props.onClick}
> >
{props.children} {props.children}
</AxoButton.Root> </AxoButton.Root>

View File

@@ -156,6 +156,9 @@ export type PropsType = {
// LowDiskSpaceBackupImportModal // LowDiskSpaceBackupImportModal
lowDiskSpaceBackupImportModal: { bytesNeeded: number } | null; lowDiskSpaceBackupImportModal: { bytesNeeded: number } | null;
hideLowDiskSpaceBackupImportModal: () => void; hideLowDiskSpaceBackupImportModal: () => void;
// PlaintextExportWorkflow
shouldShowPlaintextExportWorkflow: boolean;
renderPlaintextExportWorkflow: () => JSX.Element;
}; };
export function GlobalModalContainer({ export function GlobalModalContainer({
@@ -255,13 +258,21 @@ export function GlobalModalContainer({
// LowDiskSpaceBackupImportModal // LowDiskSpaceBackupImportModal
lowDiskSpaceBackupImportModal, lowDiskSpaceBackupImportModal,
hideLowDiskSpaceBackupImportModal, hideLowDiskSpaceBackupImportModal,
// PlaintextExportWorkflow
shouldShowPlaintextExportWorkflow,
renderPlaintextExportWorkflow,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
// We want the following dialogs to show in this order: // We want the following dialogs to show in this order:
// 0. Stateful multi-modal workflows
// 1. Errors // 1. Errors
// 2. Safety Number Changes // 2. Safety Number Changes
// 3. Forward Modal, so other modals can open it // 3. Forward Modal, so other modals can open it
// 4. The Rest (in no particular order, but they're ordered alphabetically) // 4. The Rest (in no particular order, but they're ordered alphabetically)
if (shouldShowPlaintextExportWorkflow) {
return renderPlaintextExportWorkflow();
}
// Errors // Errors
if (errorModalProps) { if (errorModalProps) {
return renderErrorModal(errorModalProps); return renderErrorModal(errorModalProps);

View File

@@ -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<PropsType>;
export function ConfirmingExport(args: PropsType): JSX.Element {
return <PlaintextExportWorkflow {...args} />;
}
export function ConfirmingWithOS(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.ConfirmingWithOS,
includeMedia: true,
}}
/>
);
}
export function ChoosingLocation(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.ChoosingLocation,
includeMedia: true,
}}
/>
);
}
export function ExportingMessages(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.ExportingMessages,
abortController: new AbortController(),
exportInBackground: false,
exportPath: '/somewhere',
}}
/>
);
}
export function ExportingAttachments(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.ExportingAttachments,
abortController: new AbortController(),
exportInBackground: false,
exportPath: '/somewhere',
progress: {
totalBytes: 1000000,
currentBytes: 500000,
},
}}
/>
);
}
export function CompleteMac(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
osName="macos"
workflow={{
step: PlaintextExportSteps.Complete,
exportPath: '/somewhere',
}}
/>
);
}
export function CompleteLinux(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
osName="windows"
workflow={{
step: PlaintextExportSteps.Complete,
exportPath: '/somewhere',
}}
/>
);
}
export function ErrorGeneric(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.Error,
errorDetails: {
type: PlaintextExportErrors.General,
},
}}
/>
);
}
export function ErrorNotEnoughStorage(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.Error,
errorDetails: {
type: PlaintextExportErrors.NotEnoughStorage,
bytesNeeded: 12000000,
},
}}
/>
);
}
export function ErrorRanOutOfStorage(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.Error,
errorDetails: {
type: PlaintextExportErrors.RanOutOfStorage,
bytesNeeded: 12000000,
},
}}
/>
);
}
export function ErrorStoragePermissions(args: PropsType): JSX.Element {
return (
<PlaintextExportWorkflow
{...args}
workflow={{
step: PlaintextExportSteps.Error,
errorDetails: {
type: PlaintextExportErrors.StoragePermissions,
},
}}
/>
);
}

View File

@@ -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<string | JSX.Element>) {
return <b>{parts}</b>;
}
function Secondary(parts: Array<string | JSX.Element>) {
return <span className={tw('text-label-secondary')}>{parts}</span>;
}
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 (
<AxoDialog.Root open onOpenChange={clearWorkflow}>
<AxoDialog.Content size="md" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>
<div className={tw('pt-[10px]')}>
{i18n('icu:PlaintextExport--Confirmation--Header')}
</div>
</AxoDialog.Title>
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<div className={tw('px-[13px]')}>
<div className={tw('text-label-secondary')}>
<I18n
i18n={i18n}
id="icu:PlaintextExport--Confirmation--Description"
components={{
bold: Bold,
}}
/>
</div>
<label
className={tw('mt-2 flex items-center py-[10px] ps-4')}
htmlFor="includ eMediaCheckbox"
>
<AxoCheckbox.Root
id="includeMediaCheckbox"
variant="square"
disabled={shouldShowSpinner}
checked={includeMedia}
onCheckedChange={value => setIncludeMedia(value)}
/>
<div className={tw('ps-2')}>
<I18n
i18n={i18n}
id="icu:PlaintextExport--Confirmation--IncludeMedia"
components={{
secondary: Secondary,
}}
/>
</div>
</label>
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={clearWorkflow}>
{i18n('icu:cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="primary"
experimentalSpinner={
shouldShowSpinner
? {
'aria-label': i18n(
'icu:PlaintextExport--Confirmation--WaitingLabel'
),
}
: null
}
onClick={() => verifyWithOSForExport(includeMedia)}
>
{i18n('icu:PlaintextExport--Confirmation--ContinueButton')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
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 = (
<>
<div className={tw('mb-[17px]')}>
<ProgressBar
fractionComplete={fractionComplete}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
</div>
<div className={tw('mb-1.5 text-center type-body-small font-[600]')}>
{i18n('icu:PlaintextExport--ProgressDialog--Progress', {
currentBytes: formatFileSize(progress.currentBytes),
totalBytes: formatFileSize(progress.totalBytes),
percentage: fractionComplete,
})}
</div>
</>
);
} else {
progressElements = (
<div className={tw('mb-[17px]')}>
<ProgressBar
fractionComplete={null}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
</div>
);
}
return (
<AxoDialog.Root open onOpenChange={cancelWorkflow}>
<AxoDialog.Content size="md" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>
<div className={tw('pt-[10px]')}>
{i18n('icu:PlaintextExport--ProgressDialog--Header')}
</div>
</AxoDialog.Title>
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<div className={tw('mx-auto my-[29px] w-[331px]')}>
{progressElements}
<div
className={tw(
'text-center type-body-small text-label-secondary'
)}
>
{i18n('icu:PlaintextExport--ProgressDialog--TimeWarning')}
</div>
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<div
className={tw(
// Unlike AxoDialog.Actions, we want these buttons centered
'mx-auto',
// Everything else is copied from AxoDialog.Action
'flex flex-wrap',
'max-w-full',
'items-center gap-x-2 gap-y-3'
)}
>
<AxoDialog.Action variant="secondary" onClick={cancelWorkflow}>
{i18n('icu:cancel')}
</AxoDialog.Action>
</div>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
if (step === 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 (
<AxoAlertDialog.Root open onOpenChange={clearWorkflow}>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:PlaintextExport--CompleteDialog--Header')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
<I18n
i18n={i18n}
id="icu:PlaintextExport--CompleteDialog--Description"
components={{
bold: Bold,
}}
/>
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action
variant="secondary"
onClick={() => {
openFileInFolder(workflow.exportPath);
clearWorkflow();
}}
>
{showInFolderText}
</AxoAlertDialog.Action>
<AxoAlertDialog.Action variant="primary" onClick={clearWorkflow}>
{i18n('icu:ok')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
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 (
<AxoAlertDialog.Root open onOpenChange={clearWorkflow}>
<AxoAlertDialog.Content escape="cancel-is-destructive">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>{title}</AxoAlertDialog.Title>
<AxoAlertDialog.Description>{detail}</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action variant="primary" onClick={clearWorkflow}>
{i18n('icu:ok')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
throw missingCaseError(step);
}

View File

@@ -117,6 +117,7 @@ const availableSpeakers = [
]; ];
const validateBackupResult: ExportResultType = { const validateBackupResult: ExportResultType = {
attachmentBackupJobs: [],
totalBytes: 100, totalBytes: 100,
duration: 10000, duration: 10000,
stats: { stats: {
@@ -137,6 +138,7 @@ const validateBackupResult: ExportResultType = {
const exportLocalBackupResult: LocalBackupExportResultType = { const exportLocalBackupResult: LocalBackupExportResultType = {
...validateBackupResult, ...validateBackupResult,
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
totalAttachmentBytes: 1000000,
}; };
const donationAmountsConfig = { const donationAmountsConfig = {
@@ -466,6 +468,7 @@ export default {
isContentProtectionSupported: true, isContentProtectionSupported: true,
isContentProtectionNeeded: true, isContentProtectionNeeded: true,
isMinimizeToAndStartInSystemTraySupported: true, isMinimizeToAndStartInSystemTraySupported: true,
isPlaintextExportEnabled: true,
lastSyncTime: Date.now(), lastSyncTime: Date.now(),
localeOverride: null, localeOverride: null,
localBackupFolder: undefined, localBackupFolder: undefined,
@@ -614,6 +617,7 @@ export default {
), ),
setSettingsLocation: action('setSettingsLocation'), setSettingsLocation: action('setSettingsLocation'),
showToast: action('showToast'), showToast: action('showToast'),
startPlaintextExport: action('startPlaintextExport'),
validateBackup: async () => { validateBackup: async () => {
return { return {
result: validateBackupResult, result: validateBackupResult,
@@ -703,7 +707,13 @@ export const Donations = Template.bind({});
Donations.args = { Donations.args = {
settingsLocation: { page: SettingsPage.Donations }, settingsLocation: { page: SettingsPage.Donations },
}; };
export const ChatsWithDisabledPlaintextExport = Template.bind({});
ChatsWithDisabledPlaintextExport.args = {
settingsLocation: {
page: SettingsPage.Chats,
},
isPlaintextExportEnabled: false,
};
export const NotificationsPageWithThreeProfiles = Template.bind({}); export const NotificationsPageWithThreeProfiles = Template.bind({});
const threeProfiles = [ const threeProfiles = [
{ {

View File

@@ -192,6 +192,7 @@ export type PropsDataType = {
isContentProtectionSupported: boolean; isContentProtectionSupported: boolean;
isHideMenuBarSupported: boolean; isHideMenuBarSupported: boolean;
isNotificationAttentionSupported: boolean; isNotificationAttentionSupported: boolean;
isPlaintextExportEnabled: boolean;
isSyncSupported: boolean; isSyncSupported: boolean;
isSystemTraySupported: boolean; isSystemTraySupported: boolean;
isMinimizeToAndStartInSystemTraySupported: boolean; isMinimizeToAndStartInSystemTraySupported: boolean;
@@ -269,6 +270,7 @@ type PropsFunctionType = {
) => unknown; ) => unknown;
setSettingsLocation: (settingsLocation: SettingsLocation) => unknown; setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
showToast: (toast: AnyToast) => unknown; showToast: (toast: AnyToast) => unknown;
startPlaintextExport: () => unknown;
validateBackup: () => Promise<BackupValidationResultType>; validateBackup: () => Promise<BackupValidationResultType>;
internalAddDonationReceipt: (receipt: DonationReceipt) => void; internalAddDonationReceipt: (receipt: DonationReceipt) => void;
@@ -439,6 +441,7 @@ export function Preferences({
isContentProtectionSupported, isContentProtectionSupported,
isHideMenuBarSupported, isHideMenuBarSupported,
isNotificationAttentionSupported, isNotificationAttentionSupported,
isPlaintextExportEnabled,
isSyncSupported, isSyncSupported,
isSystemTraySupported, isSystemTraySupported,
isMinimizeToAndStartInSystemTraySupported, isMinimizeToAndStartInSystemTraySupported,
@@ -519,6 +522,7 @@ export function Preferences({
setSettingsLocation, setSettingsLocation,
shouldShowUpdateDialog, shouldShowUpdateDialog,
showToast, showToast,
startPlaintextExport,
localeOverride, localeOverride,
theme, theme,
themeSetting, themeSetting,
@@ -1232,6 +1236,34 @@ export function Preferences({
</SettingsRow> </SettingsRow>
)} )}
{isPlaintextExportEnabled && (
<SettingsRow>
<Control
left={
<>
<div>
{i18n('icu:PlaintextExport--PreferencesRow--Header')}
</div>
<div className="Preferences__description">
{i18n('icu:PlaintextExport--PreferencesRow--Description')}
</div>
</>
}
right={
<div className="Preferences__right-button">
<AxoButton.Root
variant="secondary"
size="lg"
onClick={startPlaintextExport}
>
{i18n('icu:PlaintextExport--ActionButton')}
</AxoButton.Root>
</div>
}
/>
</SettingsRow>
)}
{isSyncSupported && ( {isSyncSupported && (
<SettingsRow> <SettingsRow>
<Control <Control

View File

@@ -59,6 +59,7 @@ import type {
ScheduleDays, ScheduleDays,
} from '../types/NotificationProfile.std.js'; } from '../types/NotificationProfile.std.js';
import type { SettingsLocation } from '../types/Nav.std.js'; import type { SettingsLocation } from '../types/Nav.std.js';
import { addLeadingZero } from '../util/timestamp.std.js';
enum CreateFlowPage { enum CreateFlowPage {
Name = 'Name', Name = 'Name',
@@ -163,12 +164,6 @@ function formatTimeForInput(time: number): Time {
const { hours, minutes } = getTimeDetails(time, true); const { hours, minutes } = getTimeDetails(time, true);
return new Time(hours, minutes); return new Time(hours, minutes);
} }
function addLeadingZero(minutes: number): string {
if (minutes < 10) {
return `0${minutes}`;
}
return minutes.toString();
}
function parseTimeFromInput(time: Time): number { function parseTimeFromInput(time: Time): number {
return time.hour * 100 + time.minute; return time.hour * 100 + time.minute;

View File

@@ -1,8 +1,8 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { existsSync } from 'node:fs'; import { createWriteStream, existsSync } from 'node:fs';
import { extname } from 'node:path';
import { import {
constants as FS_CONSTANTS, constants as FS_CONSTANTS,
copyFile, copyFile,
@@ -10,222 +10,53 @@ import {
rename, rename,
} from 'node:fs/promises'; } from 'node:fs/promises';
import * as durations from '../util/durations/index.std.js';
import { createLogger } from '../logging/log.std.js'; import { createLogger } from '../logging/log.std.js';
import * as Errors from '../types/errors.std.js';
import { redactGenericText } from '../util/privacy.node.js';
import { import {
getAbsoluteAttachmentPath,
getAbsoluteAttachmentPath as doGetAbsoluteAttachmentPath, getAbsoluteAttachmentPath as doGetAbsoluteAttachmentPath,
getAbsoluteTempPath, getAbsoluteTempPath as doGetAbsoluteTempPath,
} from '../util/migrations.preload.js'; } from '../util/migrations.preload.js';
import {
JobManager,
type JobManagerParamsType,
type JobManagerJobResultType,
} from './JobManager.std.js';
import {
type BackupsService,
backupsService,
} from '../services/backups/index.preload.js';
import { decryptAttachmentV2ToSink } from '../AttachmentCrypto.node.js'; import { decryptAttachmentV2ToSink } from '../AttachmentCrypto.node.js';
import {
type AttachmentLocalBackupJobType,
type CoreAttachmentLocalBackupJobType,
} from '../types/AttachmentBackup.std.js';
import { isInCall as isInCallSelector } from '../state/selectors/calling.std.js';
import { encryptAndUploadAttachment } from '../util/uploadAttachment.preload.js';
import { backupMediaBatch as doBackupMediaBatch } from '../textsecure/WebAPI.preload.js';
import { import {
getLocalBackupDirectoryForMediaName, getLocalBackupDirectoryForMediaName,
getLocalBackupPathForMediaName, getLocalBackupPathForMediaName,
} from '../services/backups/util/localBackup.node.js'; } from '../services/backups/util/localBackup.node.js';
import { createName } from '../util/attachmentPath.node.js'; import { createName } from '../util/attachmentPath.node.js';
import { redactGenericText } from '../util/privacy.node.js';
import type { CoreAttachmentLocalBackupJobType } from '../types/AttachmentBackup.std.js';
const log = createLogger('AttachmentLocalBackupManager'); const log = createLogger('AttachmentLocalBackupManager');
const MAX_CONCURRENT_JOBS = 3; export class AttachmentPermanentlyMissingError extends Error {}
const RETRY_CONFIG = {
maxAttempts: 3,
backoffConfig: {
// 1 minute, 5 minutes, 25 minutes, every hour
multiplier: 3,
firstBackoffs: [10 * durations.SECOND],
maxBackoffTime: durations.MINUTE,
},
};
export class AttachmentLocalBackupManager extends JobManager<CoreAttachmentLocalBackupJobType> {
static #instance: AttachmentLocalBackupManager | undefined;
readonly #jobsByMediaName = new Map<string, AttachmentLocalBackupJobType>();
static defaultParams: JobManagerParamsType<CoreAttachmentLocalBackupJobType> =
{
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<string, AttachmentLocalBackupJobType> {
return AttachmentLocalBackupManager.instance.#jobsByMediaName;
}
static async start(): Promise<void> {
log.info('starting');
await AttachmentLocalBackupManager.instance.start();
}
static async stop(): Promise<void> {
log.info('stopping');
return AttachmentLocalBackupManager.#instance?.stop();
}
static async addJob(newJob: CoreAttachmentLocalBackupJobType): Promise<void> {
return AttachmentLocalBackupManager.instance.addJob(newJob);
}
static async waitForIdle(): Promise<void> {
return AttachmentLocalBackupManager.instance.waitForIdle();
}
static async markAllJobsInactive(): Promise<void> {
for (const [mediaName, job] of AttachmentLocalBackupManager.jobs) {
AttachmentLocalBackupManager.jobs.set(mediaName, {
...job,
active: false,
});
}
}
static async saveJob(job: AttachmentLocalBackupJobType): Promise<void> {
AttachmentLocalBackupManager.jobs.set(job.mediaName, job);
}
static async removeJob(
job: Pick<AttachmentLocalBackupJobType, 'mediaName'>
): Promise<void> {
AttachmentLocalBackupManager.jobs.delete(job.mediaName);
}
static clearAllJobs(): void {
AttachmentLocalBackupManager.jobs.clear();
}
static async getNextJobs({
limit,
timestamp,
}: {
limit: number;
timestamp: number;
}): Promise<Array<AttachmentLocalBackupJobType>> {
let countRemaining = limit;
const nextJobs: Array<AttachmentLocalBackupJobType> = [];
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 {}
type RunAttachmentBackupJobDependenciesType = { type RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath: typeof doGetAbsoluteAttachmentPath; getAbsoluteAttachmentPath: typeof doGetAbsoluteAttachmentPath;
backupMediaBatch?: typeof doBackupMediaBatch; getAbsoluteTempPath: typeof doGetAbsoluteAttachmentPath;
backupsService: BackupsService;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
}; };
export async function runAttachmentBackupJob( export function getJobIdForLogging(
job: AttachmentLocalBackupJobType, job: CoreAttachmentLocalBackupJobType
_options: { ): string {
isLastAttempt: boolean; return `${redactGenericText(job.mediaName)}`;
abortSignal: AbortSignal;
},
dependencies: RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath,
backupsService,
backupMediaBatch: doBackupMediaBatch,
encryptAndUploadAttachment,
decryptAttachmentV2ToSink,
}
): Promise<JobManagerJobResultType<CoreAttachmentLocalBackupJobType>> {
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' };
}
} }
async function runAttachmentBackupJobInner( export async function runAttachmentBackupJob(
job: AttachmentLocalBackupJobType, job: CoreAttachmentLocalBackupJobType,
dependencies: RunAttachmentBackupJobDependenciesType backupsBaseDir: string,
dependencies: RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath,
getAbsoluteTempPath: doGetAbsoluteTempPath,
decryptAttachmentV2ToSink,
}
): Promise<void> { ): Promise<void> {
const jobIdForLogging = getJobIdForLogging(job); 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 { isPlaintextExport, mediaName } = job;
const { path } = job.data; const { contentType, fileName, localKey, path, size } = job.data;
if (!path) { if (!path) {
throw new AttachmentPermanentlyMissingError('No path property'); throw new AttachmentPermanentlyMissingError('No path property');
@@ -240,22 +71,93 @@ async function runAttachmentBackupJobInner(
backupsBaseDir, backupsBaseDir,
mediaName, mediaName,
}); });
await mkdir(localBackupFileDir, { recursive: true }); await mkdir(localBackupFileDir, { recursive: true });
const sourceAttachmentPath = dependencies.getAbsoluteAttachmentPath(path);
const destinationLocalBackupFilePath = getLocalBackupPathForMediaName({ const destinationLocalBackupFilePath = getLocalBackupPathForMediaName({
backupsBaseDir, backupsBaseDir,
mediaName, mediaName,
}); });
// File is already encrypted with localKey, so we just have to copy it to the backup dir if (isPlaintextExport) {
const sourceAttachmentPath = getAbsoluteAttachmentPath(path); const extension = getExtension(contentType, fileName);
const tempPath = getAbsoluteTempPath(createName()); 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 // 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 // the same mediaName at a time, but just to be safe, we copy to temp file and rename
// ensure the atomicity of the copy operation // to ensure the atomicity of the copy operation
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy) // Set COPYFILE_FICLONE for Copy on Write (OS dependent, graceful fallback to copy)
await copyFile(sourceAttachmentPath, tempPath, FS_CONSTANTS.COPYFILE_FICLONE); await copyFile(
await rename(tempPath, destinationLocalBackupFilePath); 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;
} }

View File

@@ -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 = `<description>${englishDescription}</description>\n`;
let allMessages = `<message>${englishMessage}</message>\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 xml:lang="${localeName}">${description}</description>\n`;
const message = data[template.message]?.messageformat ?? englishMessage;
allMessages += ` <message xml:lang="${localeName}">${message}</message>\n`;
}
const targetPath = path.join(ROOT_DIR, 'build', template.name);
let targetContent = template.content;
targetContent = targetContent.replace(
'<!-- <description>{description}</description> -->',
allDescriptions
);
targetContent = targetContent.replace(
'<!-- <message>{message}</message> -->',
allMessages
);
// eslint-disable-next-line no-await-in-loop
await fs.writeFile(targetPath, targetContent);
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -3,7 +3,7 @@
import Long from 'long'; import Long from 'long';
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; 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 pMap from 'p-map';
import pTimeout from 'p-timeout'; import pTimeout from 'p-timeout';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
@@ -140,10 +140,9 @@ import { getFilePointerForAttachment } from './util/filePointers.preload.js';
import { getBackupMediaRootKey } from './crypto.preload.js'; import { getBackupMediaRootKey } from './crypto.preload.js';
import type { import type {
CoreAttachmentBackupJobType, CoreAttachmentBackupJobType,
PartialAttachmentLocalBackupJobType, CoreAttachmentLocalBackupJobType,
} from '../../types/AttachmentBackup.std.js'; } from '../../types/AttachmentBackup.std.js';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager.preload.js'; import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager.preload.js';
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager.preload.js';
import { import {
getBackupCdnInfo, getBackupCdnInfo,
getLocalBackupFileNameForAttachment, getLocalBackupFileNameForAttachment,
@@ -196,6 +195,7 @@ const FLUSH_TIMEOUT = 30 * MINUTE;
const REPORTING_THRESHOLD = SECOND; const REPORTING_THRESHOLD = SECOND;
const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE; const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE;
const BACKUP_QUOTE_BODY_LIMIT = 2048;
type GetRecipientIdOptionsType = type GetRecipientIdOptionsType =
| Readonly<{ | Readonly<{
@@ -270,11 +270,12 @@ export class BackupExportStream extends Readable {
}; };
#ourConversation?: ConversationAttributesType; #ourConversation?: ConversationAttributesType;
#attachmentBackupJobs: Array< #attachmentBackupJobs: Array<
CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
> = []; > = [];
#buffers = new Array<Uint8Array>(); #buffers = new Array<Uint8Array>();
#nextRecipientId = 1; #nextRecipientId = 1;
#flushResolve: (() => void) | undefined; #flushResolve: (() => void) | undefined;
#jsonExporter: BackupJsonExporter | undefined;
// Map from custom color uuid to an index in accountSettings.customColors // Map from custom color uuid to an index in accountSettings.customColors
// array. // array.
@@ -289,7 +290,6 @@ export class BackupExportStream extends Readable {
(async () => { (async () => {
log.info('BackupExportStream: starting...'); log.info('BackupExportStream: starting...');
drop(AttachmentBackupManager.stop()); drop(AttachmentBackupManager.stop());
drop(AttachmentLocalBackupManager.stop());
log.info('BackupExportStream: message migration starting...'); log.info('BackupExportStream: message migration starting...');
await migrateAllMessages(); await migrateAllMessages();
@@ -303,34 +303,6 @@ export class BackupExportStream extends Readable {
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction // TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
const { type } = this.options; const { type } = this.options;
switch (type) { 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': case 'remote':
await DataWriter.clearAllAttachmentBackupJobs(); await DataWriter.clearAllAttachmentBackupJobs();
await Promise.all( await Promise.all(
@@ -347,14 +319,21 @@ export class BackupExportStream extends Readable {
); );
}) })
); );
drop(AttachmentBackupManager.start()); this.#attachmentBackupJobs = [];
break; break;
case 'plaintext-export':
case 'local-encrypted':
case 'cross-client-integration-test': case 'cross-client-integration-test':
log.info(
`Type is ${this.options.type}, not doing anything with ${this.#attachmentBackupJobs.length} attachment jobs`
);
break; break;
default: default:
// eslint-disable-next-line no-unsafe-finally // eslint-disable-next-line no-unsafe-finally
throw missingCaseError(type); throw missingCaseError(type);
} }
drop(AttachmentBackupManager.start());
log.info('BackupExportStream: finished'); log.info('BackupExportStream: finished');
} }
})() })()
@@ -368,18 +347,29 @@ export class BackupExportStream extends Readable {
public getStats(): Readonly<StatsType> { public getStats(): Readonly<StatsType> {
return this.#stats; return this.#stats;
} }
public getAttachmentBackupJobs(): ReadonlyArray<
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
> {
return this.#attachmentBackupJobs;
}
async #unsafeRun(): Promise<void> { async #unsafeRun(): Promise<void> {
this.#ourConversation = this.#ourConversation =
window.ConversationController.getOurConversationOrThrow().attributes; 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( this.push(
Backups.BackupInfo.encodeDelimited({ this.#getJsonIfNeeded(
version: Long.fromNumber(BACKUP_VERSION), this.options.type === 'plaintext-export'
backupTimeMs: this.#backupTimeMs, ? Backups.BackupInfo.encode(backupInfo).finish()
mediaRootBackupKey: getBackupMediaRootKey().serialize(), : Backups.BackupInfo.encodeDelimited(backupInfo).finish()
firstAppVersion: itemStorage.get('restoredBackupFirstAppVersion'), )
currentAppVersion: `Desktop ${window.getVersion()}`,
}).finish()
); );
this.#pushFrame({ this.#pushFrame({
@@ -779,6 +769,11 @@ export class BackupExportStream extends Readable {
await pMap( await pMap(
messages, messages,
async message => { async message => {
if (skippedConversationIds.has(message.conversationId)) {
this.#stats.skippedMessages += 1;
return;
}
const chatItem = await this.#toChatItem(message, { const chatItem = await this.#toChatItem(message, {
aboutMe, aboutMe,
callHistoryByCallId, callHistoryByCallId,
@@ -817,6 +812,22 @@ export class BackupExportStream extends Readable {
attachmentBackupJobs: this.#attachmentBackupJobs.length, 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); this.push(null);
} }
@@ -824,8 +835,50 @@ export class BackupExportStream extends Readable {
this.#buffers.push(buffer); 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 { #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<void> { async #flush(): Promise<void> {
@@ -893,6 +946,9 @@ export class BackupExportStream extends Readable {
const backupsSubscriberData = generateBackupsSubscriberData(); const backupsSubscriberData = generateBackupsSubscriberData();
const backupTier = itemStorage.get('backupTier'); const backupTier = itemStorage.get('backupTier');
const autoDownloadPrimary = itemStorage.get(
'auto-download-attachment-primary'
);
return { return {
profileKey: itemStorage.get('profileKey'), profileKey: itemStorage.get('profileKey'),
@@ -921,6 +977,14 @@ export class BackupExportStream extends Readable {
} }
: null, : null,
svrPin: itemStorage.get('svrPin'), svrPin: itemStorage.get('svrPin'),
bioText: me.get('about'),
bioEmoji: me.get('aboutEmoji'),
// Test only values
...(isTestOrMockEnvironment()
? {
androidSpecificSettings: itemStorage.get('androidSpecificSettings'),
}
: {}),
accountSettings: { accountSettings: {
readReceipts: itemStorage.get('read-receipt-setting'), readReceipts: itemStorage.get('read-receipt-setting'),
sealedSenderIndicators: itemStorage.get('sealedSenderIndicators'), sealedSenderIndicators: itemStorage.get('sealedSenderIndicators'),
@@ -957,8 +1021,24 @@ export class BackupExportStream extends Readable {
optimizeOnDeviceStorage: itemStorage.get( optimizeOnDeviceStorage: itemStorage.get(
'optimizeOnDeviceStorage' '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 (message.expireTimer) {
if (this.options.type === 'plaintext-export') {
// All disappearing messages are excluded in plaintext export
return undefined;
}
if (DurationInSeconds.toMillis(message.expireTimer) <= DAY) { if (DurationInSeconds.toMillis(message.expireTimer) <= DAY) {
// Message has an expire timer that's too short for export // Message has an expire timer that's too short for export
return undefined; return undefined;
@@ -2562,7 +2647,7 @@ export class BackupExportStream extends Readable {
text: text:
quote.text != null quote.text != null
? { ? {
body: quote.text, body: trimBody(quote.text, BACKUP_QUOTE_BODY_LIMIT),
bodyRanges: quote.bodyRanges?.map(range => bodyRanges: quote.bodyRanges?.map(range =>
this.#toBodyRange(range) this.#toBodyRange(range)
), ),
@@ -2670,7 +2755,10 @@ export class BackupExportStream extends Readable {
}); });
let mediaName: string | undefined; let mediaName: string | undefined;
if (this.options.type === 'local-encrypted') { if (
this.options.type === 'local-encrypted' ||
this.options.type === 'plaintext-export'
) {
if (hasRequiredInformationForLocalBackup(attachment)) { if (hasRequiredInformationForLocalBackup(attachment)) {
mediaName = getLocalBackupFileNameForAttachment(attachment); mediaName = getLocalBackupFileNameForAttachment(attachment);
} }
@@ -3002,7 +3090,8 @@ export class BackupExportStream extends Readable {
const attachment = message.attachments?.at(0); const attachment = message.attachments?.at(0);
// Integration tests use the 'link-and-sync' version of export, which will include // Integration tests use the 'link-and-sync' version of export, which will include
// view-once attachments // view-once attachments
const shouldIncludeAttachments = isTestOrMockEnvironment(); const shouldIncludeAttachments =
this.options.type !== 'plaintext-export' && isTestOrMockEnvironment();
return { return {
attachment: attachment:
!shouldIncludeAttachments || attachment == null !shouldIncludeAttachments || attachment == null

View File

@@ -722,6 +722,9 @@ export class BackupImportStream extends Writable {
donationSubscriberData, donationSubscriberData,
accountSettings, accountSettings,
svrPin, svrPin,
androidSpecificSettings,
bioText,
bioEmoji,
}: Backups.IAccountData): Promise<void> { }: Backups.IAccountData): Promise<void> {
strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData');
const me = { const me = {
@@ -757,6 +760,12 @@ export class BackupImportStream extends Writable {
if (familyName != null) { if (familyName != null) {
me.profileFamilyName = familyName; me.profileFamilyName = familyName;
} }
if (bioText != null) {
me.about = bioText;
}
if (bioEmoji != null) {
me.aboutEmoji = bioEmoji;
}
if (avatarUrlPath != null) { if (avatarUrlPath != null) {
await itemStorage.put('avatarUrl', avatarUrlPath); 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); await saveBackupsSubscriberData(backupsSubscriberData);
@@ -855,6 +868,26 @@ export class BackupImportStream extends Writable {
'optimizeOnDeviceStorage', 'optimizeOnDeviceStorage',
accountSettings?.optimizeOnDeviceStorage === true 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(); this.#backupTier = accountSettings?.backupTier?.toNumber();
@@ -863,6 +896,18 @@ export class BackupImportStream extends Writable {
accountSettings?.backupTier?.toNumber() 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; const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData;
switch (accountSettings?.phoneNumberSharingMode) { switch (accountSettings?.phoneNumberSharingMode) {
case BackupMode.EVERYBODY: case BackupMode.EVERYBODY:

View File

@@ -5,7 +5,7 @@ import { pipeline } from 'node:stream/promises';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import type { Readable, Writable } from 'node:stream'; import type { Readable, Writable } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs'; 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 fsExtra from 'fs-extra';
import { join } from 'node:path'; import { join } from 'node:path';
import { createGzip, createGunzip } from 'node:zlib'; import { createGzip, createGunzip } from 'node:zlib';
@@ -74,6 +74,7 @@ import type {
BackupImportOptions, BackupImportOptions,
ExportResultType, ExportResultType,
LocalBackupExportResultType, LocalBackupExportResultType,
OnProgressCallback,
} from './types.std.js'; } from './types.std.js';
import { import {
BackupInstallerError, BackupInstallerError,
@@ -94,11 +95,27 @@ import {
readLocalBackupFilesList, readLocalBackupFilesList,
validateLocalBackupStructure, validateLocalBackupStructure,
} from './util/localBackup.node.js'; } 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 { decipherWithAesKey } from '../../util/decipherWithAesKey.node.js';
import { areRemoteBackupsTurnedOn } from '../../util/isBackupEnabled.preload.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 { 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; const { ensureFile } = fsExtra;
@@ -320,42 +337,148 @@ export class BackupsService {
} }
public async exportLocalBackup( public async exportLocalBackup(
backupsBaseDir: string | undefined = undefined backupsBaseDir: string,
options: BackupExportOptions
): Promise<LocalBackupExportResultType> { ): Promise<LocalBackupExportResultType> {
strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled'); 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 = const baseDir =
backupsBaseDir ?? backupsBaseDir ??
join(window.SignalContext.getPath('userData'), 'SignalBackups'); 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 }); 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, { log.info(`exportLocalBackup: starting with type=${options.type}`);
type: 'local-encrypted',
localBackupSnapshotDir: snapshotDir, 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'); log.info('exportLocalBackup: writing metadata');
const metadataArgs = { if (isPlaintextExport) {
snapshotDir, const metadataPath = join(snapshotDir, 'metadata.json');
backupId: getBackupId(), await writeFile(
metadataKey: getLocalBackupMetadataKey(), metadataPath,
}; JSON.stringify({
await writeLocalBackupMetadata(metadataArgs); version: LOCAL_BACKUP_VERSION,
await verifyLocalBackupMetadata(metadataArgs); })
);
log.info( } else {
'exportLocalBackup: waiting for AttachmentLocalBackupManager to finish' const metadataArgs = {
); snapshotDir,
await AttachmentLocalBackupManager.waitForIdle(); backupId: getBackupId(),
metadataKey: getLocalBackupMetadataKey(),
};
await writeLocalBackupMetadata(metadataArgs);
await verifyLocalBackupMetadata(metadataArgs);
}
log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`); log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`);
return { ...exportResult, snapshotDir }; return { ...exportResult, snapshotDir, totalAttachmentBytes };
} }
public async stageLocalBackupForImport( public async stageLocalBackupForImport(
@@ -431,7 +554,7 @@ export class BackupsService {
options options
); );
if (options.type !== 'cross-client-integration-test') { if (options.type === 'local-encrypted' || options.type === 'remote') {
await validateBackup( await validateBackup(
() => new FileStream(path), () => new FileStream(path),
exportResult.totalBytes, exportResult.totalBytes,
@@ -453,13 +576,68 @@ export class BackupsService {
return { error: 'Backups directory not selected' }; 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 }; return { result };
} catch (error) { } catch (error) {
return { error: Errors.toLogFormat(error) }; return { error: Errors.toLogFormat(error) };
} }
} }
public async exportPlaintext({
abortSignal,
onProgress,
shouldIncludeMedia,
targetPath,
}: {
abortSignal: AbortSignal;
onProgress: OnProgressCallback;
shouldIncludeMedia: boolean;
targetPath: string;
}): Promise<LocalBackupExportResultType> {
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<ValidateLocalBackupStructureResultType> { public async _internalStageLocalBackupForImport(): Promise<ValidateLocalBackupStructureResultType> {
const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke( const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
'show-open-folder-dialog' 'show-open-folder-dialog'
@@ -492,7 +670,12 @@ export class BackupsService {
const duration = Date.now() - start; const duration = Date.now() - start;
return { return {
result: { duration, stats: recordStream.getStats(), totalBytes }, result: {
attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
duration,
stats: recordStream.getStats(),
totalBytes,
},
}; };
} catch (error) { } catch (error) {
return { error: Errors.toLogFormat(error) }; return { error: Errors.toLogFormat(error) };
@@ -902,12 +1085,20 @@ export class BackupsService {
if (window.SignalCI || options.type === 'cross-client-integration-test') { if (window.SignalCI || options.type === 'cross-client-integration-test') {
strictAssert( strictAssert(
isTestOrMockEnvironment(), 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 { } else {
// We first fetch the latest info on what's on the CDN, since this affects the // We first fetch the latest info on what's on the CDN, since this affects the
// filePointers we will generate during export // filePointers we will generate during export
log.info('Fetching latest backup CDN metadata'); log.info('exportBackup: Fetching latest backup CDN metadata');
await this.fetchAndSaveBackupCdnObjectMetadata(); await this.fetchAndSaveBackupCdnObjectMetadata();
} }
@@ -942,9 +1133,28 @@ export class BackupsService {
case 'cross-client-integration-test': case 'cross-client-integration-test':
strictAssert( strictAssert(
isTestOrMockEnvironment(), 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; break;
default: default:
throw missingCaseError(type); throw missingCaseError(type);
@@ -966,7 +1176,12 @@ export class BackupsService {
} }
const duration = Date.now() - start; const duration = Date.now() - start;
return { totalBytes, stats: recordStream.getStats(), duration }; return {
attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
totalBytes,
stats: recordStream.getStats(),
duration,
};
} finally { } finally {
log.info('exportBackup: finished...'); log.info('exportBackup: finished...');
this.#isRunning = false; this.#isRunning = false;

View File

@@ -3,6 +3,10 @@
import type { AciString, PniString } from '../../types/ServiceId.std.js'; import type { AciString, PniString } from '../../types/ServiceId.std.js';
import type { ConversationColorType } from '../../types/Colors.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 // Duplicated here to allow loading it in a non-node environment
export enum BackupLevel { export enum BackupLevel {
@@ -25,8 +29,22 @@ export type AboutMe = {
e164?: string; e164?: string;
}; };
export type OnProgressCallback = (
currentBytes: number,
totalBytes: number
) => void;
export type BackupExportOptions = 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'; type: 'local-encrypted';
localBackupSnapshotDir: string; localBackupSnapshotDir: string;
@@ -39,7 +57,7 @@ export type BackupImportOptions = (
} }
) & { ) & {
ephemeralKey?: Uint8Array; ephemeralKey?: Uint8Array;
onProgress?: (currentBytes: number, totalBytes: number) => void; onProgress?: OnProgressCallback;
}; };
export type LocalChatStyle = Readonly<{ export type LocalChatStyle = Readonly<{
@@ -66,6 +84,9 @@ export type StatsType = {
}; };
export type ExportResultType = Readonly<{ export type ExportResultType = Readonly<{
attachmentBackupJobs: ReadonlyArray<
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
>;
totalBytes: number; totalBytes: number;
duration: number; duration: number;
stats: Readonly<StatsType>; stats: Readonly<StatsType>;
@@ -73,4 +94,5 @@ export type ExportResultType = Readonly<{
export type LocalBackupExportResultType = ExportResultType & { export type LocalBackupExportResultType = ExportResultType & {
snapshotDir: string; snapshotDir: string;
totalAttachmentBytes: number;
}; };

View File

@@ -22,7 +22,7 @@ import {
import { strictAssert } from '../../../util/assert.std.js'; import { strictAssert } from '../../../util/assert.std.js';
import type { import type {
CoreAttachmentBackupJobType, CoreAttachmentBackupJobType,
PartialAttachmentLocalBackupJobType, CoreAttachmentLocalBackupJobType,
} from '../../../types/AttachmentBackup.std.js'; } from '../../../types/AttachmentBackup.std.js';
import { import {
type GetBackupCdnInfoType, type GetBackupCdnInfoType,
@@ -208,7 +208,7 @@ export async function getFilePointerForAttachment({
messageReceivedAt: number; messageReceivedAt: number;
}): Promise<{ }): Promise<{
filePointer: Backups.FilePointer; filePointer: Backups.FilePointer;
backupJob?: CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType; backupJob?: CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType;
}> { }> {
const attachment = maybeFixupAttachment(rawAttachment); const attachment = maybeFixupAttachment(rawAttachment);
@@ -246,7 +246,9 @@ export async function getFilePointerForAttachment({
? await getBackupCdnInfo(remoteMediaId.string) ? await getBackupCdnInfo(remoteMediaId.string)
: { isInBackupTier: false }; : { isInBackupTier: false };
const isLocalBackup = backupOptions.type === 'local-encrypted'; const isLocalBackup =
backupOptions.type === 'local-encrypted' ||
backupOptions.type === 'plaintext-export';
filePointer.locatorInfo = getLocatorInfoForAttachment({ filePointer.locatorInfo = getLocatorInfoForAttachment({
attachment, attachment,
backupOptions, backupOptions,
@@ -262,10 +264,15 @@ export async function getFilePointerForAttachment({
return { return {
filePointer, filePointer,
backupJob: { backupJob: {
isPlaintextExport: backupOptions.type === 'plaintext-export',
mediaName: getLocalBackupFileNameForAttachment(attachment), mediaName: getLocalBackupFileNameForAttachment(attachment),
type: 'local', type: 'local',
data: { data: {
contentType: attachment.contentType,
fileName: attachment.fileName,
localKey: attachment.localKey,
path: attachment.path, path: attachment.path,
size: attachment.size,
}, },
}, },
}; };
@@ -362,7 +369,9 @@ function getLocatorInfoForAttachment({
}): Backups.FilePointer.LocatorInfo { }): Backups.FilePointer.LocatorInfo {
const locatorInfo = new 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 = const shouldBeLocallyBackedUp =
isLocalBackup && isLocalBackup &&

View File

@@ -46,7 +46,10 @@ export async function validateBackup(
`Backup validation failed: ${outcome.errorMessage}` `Backup validation failed: ${outcome.errorMessage}`
); );
} else if (type === ValidationType.Export) { } else if (type === ValidationType.Export) {
strictAssert(outcome.ok, 'Backup validation failed'); strictAssert(
outcome.ok,
`Backup validation failed: ${outcome.errorMessage}`
);
} else { } else {
throw missingCaseError(type); throw missingCaseError(type);
} }

View File

@@ -70,7 +70,7 @@ export function createExpiringEntityCleanupService(
function cancelNextScheduledRun(reason: string) { function cancelNextScheduledRun(reason: string) {
if (controller != null) { if (controller != null) {
log.warn(`cancel(${reason}) cancelling next scheduled run`); log.warn(`cancel(${reason}) canceling next scheduled run`);
controller.abort(reason); controller.abort(reason);
controller = null; controller = null;
} }

View File

@@ -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 app } from './ducks/app.preload.js';
import { actions as audioPlayer } from './ducks/audioPlayer.preload.js'; import { actions as audioPlayer } from './ducks/audioPlayer.preload.js';
import { actions as audioRecorder } from './ducks/audioRecorder.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 badges } from './ducks/badges.preload.js';
import { actions as callHistory } from './ducks/callHistory.preload.js'; import { actions as callHistory } from './ducks/callHistory.preload.js';
import { actions as calling } from './ducks/calling.preload.js'; import { actions as calling } from './ducks/calling.preload.js';
@@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = {
app, app,
audioPlayer, audioPlayer,
audioRecorder, audioRecorder,
backups,
badges, badges,
callHistory, callHistory,
calling, calling,

View File

@@ -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<SetWorkflowAction>;
// 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<void, StateType, unknown, SetWorkflowAction> {
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<void, StateType, unknown, SetWorkflowAction> {
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;
}

View File

@@ -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 appEmptyState } from './ducks/app.preload.js';
import { getEmptyState as audioPlayerEmptyState } from './ducks/audioPlayer.preload.js'; import { getEmptyState as audioPlayerEmptyState } from './ducks/audioPlayer.preload.js';
import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder.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 badgesEmptyState } from './ducks/badges.preload.js';
import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.preload.js'; import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.preload.js';
import { getEmptyState as callingEmptyState } from './ducks/calling.preload.js'; import { getEmptyState as callingEmptyState } from './ducks/calling.preload.js';
@@ -149,6 +150,7 @@ function getEmptyState(): StateType {
app: appEmptyState(), app: appEmptyState(),
audioPlayer: audioPlayerEmptyState(), audioPlayer: audioPlayerEmptyState(),
audioRecorder: audioRecorderEmptyState(), audioRecorder: audioRecorderEmptyState(),
backups: backupsEmptyState(),
badges: badgesEmptyState(), badges: badgesEmptyState(),
callHistory: callHistoryEmptyState(), callHistory: callHistoryEmptyState(),
calling: callingEmptyState(), calling: callingEmptyState(),

View File

@@ -56,6 +56,7 @@ export function initializeRedux(data: ReduxInitData): void {
actionCreators.audioRecorder, actionCreators.audioRecorder,
store.dispatch store.dispatch
), ),
backups: bindActionCreators(actionCreators.backups, store.dispatch),
badges: bindActionCreators(actionCreators.badges, store.dispatch), badges: bindActionCreators(actionCreators.badges, store.dispatch),
callHistory: bindActionCreators(actionCreators.callHistory, store.dispatch), callHistory: bindActionCreators(actionCreators.callHistory, store.dispatch),
calling: bindActionCreators(actionCreators.calling, store.dispatch), calling: bindActionCreators(actionCreators.calling, store.dispatch),

View File

@@ -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 app } from './ducks/app.preload.js';
import { reducer as audioPlayer } from './ducks/audioPlayer.preload.js'; import { reducer as audioPlayer } from './ducks/audioPlayer.preload.js';
import { reducer as audioRecorder } from './ducks/audioRecorder.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 badges } from './ducks/badges.preload.js';
import { reducer as calling } from './ducks/calling.preload.js'; import { reducer as calling } from './ducks/calling.preload.js';
import { reducer as callHistory } from './ducks/callHistory.preload.js'; import { reducer as callHistory } from './ducks/callHistory.preload.js';
@@ -44,6 +45,7 @@ export const reducer = combineReducers({
app, app,
audioPlayer, audioPlayer,
audioRecorder, audioRecorder,
backups,
badges, badges,
calling, calling,
callHistory, callHistory,

View File

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

View File

@@ -32,6 +32,8 @@ import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipa
import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.js'; import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.js';
import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal.preload.js'; import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal.preload.js';
import { DebugLogErrorModal } from '../../components/DebugLogErrorModal.dom.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 { function renderCallLinkAddNameModal(): JSX.Element {
return <SmartCallLinkAddNameModal />; return <SmartCallLinkAddNameModal />;
@@ -89,6 +91,10 @@ function renderNotePreviewModal(): JSX.Element {
return <SmartNotePreviewModal />; return <SmartNotePreviewModal />;
} }
function renderPlaintextExportWorkflow(): JSX.Element {
return <SmartPlaintextExportWorkflow />;
}
function renderStoriesSettings(): JSX.Element { function renderStoriesSettings(): JSX.Element {
return <SmartStoriesSettingsModal />; return <SmartStoriesSettingsModal />;
} }
@@ -110,6 +116,9 @@ export const SmartGlobalModalContainer = memo(
const conversationsStoppingSend = useSelector(getConversationsStoppingSend); const conversationsStoppingSend = useSelector(getConversationsStoppingSend);
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const shouldShowPlaintextExportWorkflow = useSelector(
shouldShowPlaintextWorkflow
);
const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0; const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0;
@@ -282,6 +291,7 @@ export const SmartGlobalModalContainer = memo(
renderMessageRequestActionsConfirmation renderMessageRequestActionsConfirmation
} }
renderNotePreviewModal={renderNotePreviewModal} renderNotePreviewModal={renderNotePreviewModal}
renderPlaintextExportWorkflow={renderPlaintextExportWorkflow}
renderProfileNameWarningModal={renderProfileNameWarningModal} renderProfileNameWarningModal={renderProfileNameWarningModal}
renderUsernameOnboarding={renderUsernameOnboarding} renderUsernameOnboarding={renderUsernameOnboarding}
renderSafetyNumber={renderSafetyNumber} renderSafetyNumber={renderSafetyNumber}
@@ -291,6 +301,7 @@ export const SmartGlobalModalContainer = memo(
renderStoriesSettings={renderStoriesSettings} renderStoriesSettings={renderStoriesSettings}
safetyNumberChangedBlockingData={safetyNumberChangedBlockingData} safetyNumberChangedBlockingData={safetyNumberChangedBlockingData}
safetyNumberModalContactId={safetyNumberModalContactId} safetyNumberModalContactId={safetyNumberModalContactId}
shouldShowPlaintextExportWorkflow={shouldShowPlaintextExportWorkflow}
stickerPackPreviewId={stickerPackPreviewId} stickerPackPreviewId={stickerPackPreviewId}
tapToViewNotAvailableModalProps={tapToViewNotAvailableModalProps} tapToViewNotAvailableModalProps={tapToViewNotAvailableModalProps}
theme={theme} theme={theme}

View File

@@ -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 (
<PlaintextExportWorkflow
cancelWorkflow={cancelWorkflow}
clearWorkflow={clearWorkflow}
i18n={i18n}
openFileInFolder={openFileInFolder}
osName={osName}
verifyWithOSForExport={verifyWithOSForExport}
workflow={workflow}
/>
);
}
);

View File

@@ -73,7 +73,6 @@ import {
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js'; import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
import { SmartProfileEditor } from './ProfileEditor.preload.js'; import { SmartProfileEditor } from './ProfileEditor.preload.js';
import { useNavActions } from '../ducks/nav.std.js'; import { useNavActions } from '../ducks/nav.std.js';
import type { SettingsLocation } from '../../types/Nav.std.js';
import { NavTab } from '../../types/Nav.std.js'; import { NavTab } from '../../types/Nav.std.js';
import { SmartToastManager } from './ToastManager.preload.js'; import { SmartToastManager } from './ToastManager.preload.js';
import { useToastActions } from '../ducks/toast.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 { SmartPreferencesDonations } from './PreferencesDonations.preload.js';
import { useDonationsActions } from '../ducks/donations.preload.js'; import { useDonationsActions } from '../ducks/donations.preload.js';
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt.dom.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 { import type {
StorageAccessType, StorageAccessType,
ZoomFactorType, ZoomFactorType,
@@ -100,22 +116,8 @@ import {
} from '../../util/backupMediaDownload.preload.js'; } from '../../util/backupMediaDownload.preload.js';
import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary.dom.js'; import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary.dom.js';
import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.js'; import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.js';
import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.js';
import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.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 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'; const DEFAULT_NOTIFICATION_SETTING = 'message';
@@ -226,6 +228,7 @@ export function SmartPreferences(): JSX.Element | null {
const { changeLocation } = useNavActions(); const { changeLocation } = useNavActions();
const { showToast } = useToastActions(); const { showToast } = useToastActions();
const { internalAddDonationReceipt } = useDonationsActions(); const { internalAddDonationReceipt } = useDonationsActions();
const { startPlaintextExport } = useBackupActions();
// Selectors // Selectors
@@ -585,6 +588,13 @@ export function SmartPreferences(): JSX.Element | null {
const backupLocalBackupsEnabled = isLocalBackupsEnabled(items.remoteConfig); const backupLocalBackupsEnabled = isLocalBackupsEnabled(items.remoteConfig);
const backupFreeMediaDays = getMessageQueueTime(items.remoteConfig) / DAY; 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 // Two-way items
function createItemsAccess<K extends keyof StorageAccessType>( function createItemsAccess<K extends keyof StorageAccessType>(
@@ -848,6 +858,7 @@ export function SmartPreferences(): JSX.Element | null {
isMinimizeToAndStartInSystemTraySupported isMinimizeToAndStartInSystemTraySupported
} }
isNotificationAttentionSupported={isNotificationAttentionSupported} isNotificationAttentionSupported={isNotificationAttentionSupported}
isPlaintextExportEnabled={isPlaintextExportEnabled}
isSyncSupported={isSyncSupported} isSyncSupported={isSyncSupported}
isSystemTraySupported={isSystemTraySupported} isSystemTraySupported={isSystemTraySupported}
isInternalUser={isInternalUser} isInternalUser={isInternalUser}
@@ -937,6 +948,7 @@ export function SmartPreferences(): JSX.Element | null {
setSettingsLocation={setSettingsLocation} setSettingsLocation={setSettingsLocation}
shouldShowUpdateDialog={shouldShowUpdateDialog} shouldShowUpdateDialog={shouldShowUpdateDialog}
showToast={showToast} showToast={showToast}
startPlaintextExport={startPlaintextExport}
theme={theme} theme={theme}
themeSetting={themeSetting} themeSetting={themeSetting}
universalExpireTimer={universalExpireTimer} universalExpireTimer={universalExpireTimer}

View File

@@ -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 app } from './ducks/app.preload.js';
import type { actions as audioPlayer } from './ducks/audioPlayer.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 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 badges } from './ducks/badges.preload.js';
import type { actions as callHistory } from './ducks/callHistory.preload.js'; import type { actions as callHistory } from './ducks/callHistory.preload.js';
import type { actions as calling } from './ducks/calling.preload.js'; import type { actions as calling } from './ducks/calling.preload.js';
@@ -45,6 +46,7 @@ export type ReduxActions = {
app: typeof app; app: typeof app;
audioPlayer: typeof audioPlayer; audioPlayer: typeof audioPlayer;
audioRecorder: typeof audioRecorder; audioRecorder: typeof audioRecorder;
backups: typeof backups;
badges: typeof badges; badges: typeof badges;
callHistory: typeof callHistory; callHistory: typeof callHistory;
calling: typeof calling; calling: typeof calling;

View File

@@ -477,8 +477,13 @@ describe('getFilePointerForAttachment', () => {
}), }),
}), }),
backupJob: { backupJob: {
isPlaintextExport: false,
data: { data: {
contentType: defaultAttachment.contentType,
fileName: defaultAttachment.fileName,
localKey: defaultAttachment.localKey,
path: defaultAttachment.path, path: defaultAttachment.path,
size: defaultAttachment.size,
}, },
mediaName: defaultLocalMediaName, mediaName: defaultLocalMediaName,
type: 'local', type: 'local',

View File

@@ -237,7 +237,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
// Confirm they are saved to DB // Confirm they are saved to DB
const allJobs = await getAllSavedJobs(); const allJobs = await getAllSavedJobs();
assert.strictEqual(allJobs.length, 10); assert.strictEqual(allJobs.length, 10, 'initial setup');
await backupManager?.start(); await backupManager?.start();
await waitForJobToBeStarted(jobs[2]); await waitForJobToBeStarted(jobs[2]);
@@ -246,11 +246,11 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]); assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]);
await waitForJobToBeStarted(jobs[0]); 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]]); assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]);
await waitForJobToBeCompleted(thumbnailJobs[0]); await waitForJobToBeCompleted(thumbnailJobs[0]);
assert.strictEqual(runJob.callCount, 10); assert.strictEqual(runJob.callCount, 10, 'all calls');
assertRunJobCalledWith([ assertRunJobCalledWith([
jobs[4], jobs[4],

View File

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

View File

@@ -45,21 +45,17 @@ export type ThumbnailAttachmentBackupJobType = {
export type CoreAttachmentLocalBackupJobType = { export type CoreAttachmentLocalBackupJobType = {
type: 'local'; type: 'local';
isPlaintextExport: boolean;
mediaName: string; mediaName: string;
data: { data: {
contentType: MIMEType;
fileName: string | undefined;
localKey: string;
path: string | null; path: string | null;
size: number;
}; };
backupsBaseDir: string;
}; };
export type PartialAttachmentLocalBackupJobType = Omit<
CoreAttachmentLocalBackupJobType,
'backupsBaseDir'
>;
export type AttachmentLocalBackupJobType = CoreAttachmentLocalBackupJobType &
JobManagerJobType;
const standardBackupJobDataSchema = z.object({ const standardBackupJobDataSchema = z.object({
type: z.literal('standard'), type: z.literal('standard'),
mediaName: z.string(), mediaName: z.string(),

126
ts/types/Backups.std.ts Normal file
View File

@@ -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>;
} = {
[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([]),
};

View File

@@ -45,6 +45,7 @@ export const rendererConfigSchema = z.object({
disableIPv6: z.boolean(), disableIPv6: z.boolean(),
disableScreenSecurity: z.boolean(), disableScreenSecurity: z.boolean(),
dnsFallback: DNSFallbackSchema, dnsFallback: DNSFallbackSchema,
downloadsPath: configRequiredStringSchema,
environment: environmentSchema, environment: environmentSchema,
isMockTestEnvironment: z.boolean(), isMockTestEnvironment: z.boolean(),
homePath: configRequiredStringSchema, homePath: configRequiredStringSchema,

11
ts/types/Storage.d.ts vendored
View File

@@ -246,6 +246,17 @@ export type StorageAccessType = {
// Stored solely for pesistance during import/export sequence // Stored solely for pesistance during import/export sequence
svrPin: string; svrPin: string;
optimizeOnDeviceStorage: boolean; 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'; postRegistrationSyncsStatus: 'incomplete' | 'complete';

View File

@@ -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<number> {
const { bsize, bavail } = await statfs(target);
return bsize * bavail;
}

View File

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

View File

@@ -14,7 +14,10 @@ import { missingCaseError } from '../missingCaseError.std.js';
const log = createLogger('promptOSAuthMain'); const log = createLogger('promptOSAuthMain');
export type PromptOSAuthReasonType = 'enable-backups' | 'view-aep'; export type PromptOSAuthReasonType =
| 'enable-backups'
| 'view-aep'
| 'plaintext-export';
export type PromptOSAuthResultType = export type PromptOSAuthResultType =
| 'error' | 'error'
@@ -101,6 +104,9 @@ async function promptOSAuthLinux(
'pkcheck -u --process $$ --action-id org.signalapp.enable-backups'; 'pkcheck -u --process $$ --action-id org.signalapp.enable-backups';
} else if (reason === 'view-aep') { } else if (reason === 'view-aep') {
command = 'pkcheck -u --process $$ --action-id org.signalapp.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 { } else {
throw missingCaseError(reason); throw missingCaseError(reason);
} }
@@ -112,6 +118,7 @@ async function promptOSAuthLinux(
} else if (code === 3) { } else if (code === 3) {
resolve('unauthorized'); resolve('unauthorized');
} else { } else {
log.warn(`promptOSAuthLinux: Got code ${code} from call to pkcheck`);
resolve('error'); resolve('error');
} }
}); });

View File

@@ -6,6 +6,7 @@ import type {
PromptOSAuthReasonType, PromptOSAuthReasonType,
PromptOSAuthResultType, PromptOSAuthResultType,
} from './os/promptOSAuthMain.main.js'; } from './os/promptOSAuthMain.main.js';
import { missingCaseError } from './missingCaseError.std.js';
export async function promptOSAuth( export async function promptOSAuth(
reason: PromptOSAuthReasonType reason: PromptOSAuthReasonType
@@ -16,23 +17,46 @@ export async function promptOSAuth(
// TODO: DESKTOP-8895 // TODO: DESKTOP-8895
if (window.Signal.OS.isMacOS()) { if (window.Signal.OS.isMacOS()) {
if (reason === 'enable-backups') { 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') { } 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 (window.Signal.OS.isWindows()) {
if (reason === 'enable-backups') { 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') { } 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) => { ipcRenderer.once(`prompt-os-auth:${reason}`, (_, response) => {
resolve(response ?? 'error'); resolve(response ?? 'error');
}); });
ipcRenderer.send('prompt-os-auth', { reason, localeString }); ipcRenderer.send('prompt-os-auth', {
reason,
localeString,
});
}); });
} }

View File

@@ -42,3 +42,15 @@ export const MIN_SAFE_DATE = -8640000000000000;
export function toBoundedDate(timestamp: number): Date { export function toBoundedDate(timestamp: number): Date {
return new Date(Math.max(MIN_SAFE_DATE, Math.min(timestamp, MAX_SAFE_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())}`;
}