mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-05 01:10:49 +00:00
Support for exporting chats to disk
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -234,7 +234,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
repository: 'signalapp/Signal-Message-Backup-Tests'
|
||||
ref: '455fbe5854bd3be5002f17ae929a898c0975adc4'
|
||||
ref: '551f9ad1186d196e8698df4a5750b239f0796a70'
|
||||
path: 'backup-integration-tests'
|
||||
|
||||
- run: xvfb-run --auto-servernum pnpm run test-electron
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules_bkp
|
||||
coverage/*
|
||||
build/curve25519_compiled.js
|
||||
build/compact-locales
|
||||
build/*.policy
|
||||
stylesheets/*.css.map
|
||||
/dist
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7200,6 +7200,118 @@
|
||||
"messageformat": "Notification content",
|
||||
"description": "Label for the notification content setting select box"
|
||||
},
|
||||
"icu:PlaintextExport--PreferencesRow--Header": {
|
||||
"messageformat": "Export chat history",
|
||||
"description": "Shown in the Preferences/Chats page, header for a row"
|
||||
},
|
||||
"icu:PlaintextExport--PreferencesRow--Description": {
|
||||
"messageformat": "Export a machine-readable JSON copy of all your chats",
|
||||
"description": "Shown in the Preferences/Chats page, detail text for a row"
|
||||
},
|
||||
"icu:PlaintextExport--ActionButton": {
|
||||
"messageformat": "Export",
|
||||
"description": "Shown on a button to take the action of exporting chat history"
|
||||
},
|
||||
"icu:PlaintextExport--Confirmation--Header": {
|
||||
"messageformat": "Export chat history?",
|
||||
"description": "Title of confirmation dialog shown before the export starts"
|
||||
},
|
||||
"icu:PlaintextExport--Confirmation--Description": {
|
||||
"messageformat": "<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 computer’s 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 computer’s permissions. ",
|
||||
"description": "Additional explanation on the dialog shown when the export is complete"
|
||||
},
|
||||
"icu:PlaintextExport--CompleteDialog--ShowFiles--Mac": {
|
||||
"messageformat": "Show in Finder",
|
||||
"description": "Button on the dialog shown when the export is complete to show the exported files - on a mac computer"
|
||||
},
|
||||
"icu:PlaintextExport--CompleteDialog--ShowFiles--Linux": {
|
||||
"messageformat": "Show in folder",
|
||||
"description": "Button on the dialog shown when the export is complete to show the exported files - on a linux computer"
|
||||
},
|
||||
"icu:PlaintextExport--CompleteDialog--ShowFiles--Windows": {
|
||||
"messageformat": "Show in folder",
|
||||
"description": "Button on the dialog shown when the export is complete to show the exported files - on a windows computer"
|
||||
},
|
||||
"icu:PlaintextExport--Error--General--Title": {
|
||||
"messageformat": "Couldn’t export chat history",
|
||||
"description": "Title of dialog shown when a general plaintext export error has happened"
|
||||
},
|
||||
"icu:PlaintextExport--Error--General--Description": {
|
||||
"messageformat": "An error occurred and your chat history could not be exported.",
|
||||
"description": "Detail text in dialog shown when a general plaintext export error has happened"
|
||||
},
|
||||
"icu:PlaintextExport--Error--NotEnoughStorage--Title": {
|
||||
"messageformat": "Not enough storage space",
|
||||
"description": "Title of dialog shown when we detect that user doesn't have enough space for export"
|
||||
},
|
||||
"icu:PlaintextExport--Error--NotEnoughStorage--Detail": {
|
||||
"messageformat": "Your chat history can’t be exported because your computer doesn’t have enough free storage space. Free up {bytes} of space and then try again.",
|
||||
"description": "Detail text in dialog shown when we detect that user doesn't have enough space for export. Bytes will be formatted like 12 MB"
|
||||
},
|
||||
"icu:PlaintextExport--Error--RanOutOfStorage--Title": {
|
||||
"messageformat": "Couldn’t export chat history",
|
||||
"description": "Title of dialog shown when we attempted to save a file during export but disk is out of space"
|
||||
},
|
||||
"icu:PlaintextExport--Error--RanOutOfStorage--Detail": {
|
||||
"messageformat": "Your chat history couldn’t be exported because your computer doesn’t have enough free storage space. Free up {bytes} of space and then try again.",
|
||||
"description": "Detail text in dialog shown when we attempted to save a file during export but disk is out of space. Bytes will formated like 12 MB"
|
||||
},
|
||||
"icu:PlaintextExport--Error--DiskPermssions--Title": {
|
||||
"messageformat": "Can’t export chat history",
|
||||
"description": "Title of dialog shown when we attempted to save a file during export but got a permissions error"
|
||||
},
|
||||
"icu:PlaintextExport--Error--DiskPermssions--Detail": {
|
||||
"messageformat": "Your chat history can’t be exported because Signal doesn’t have permission to write files to disk. Try changing your disk's permissions or updating your system settings and then export again.",
|
||||
"description": "Detail text in dialog shown when we attempted to save a file during export but got a permissions error"
|
||||
},
|
||||
"icu:NotificationProfile--moon-icon": {
|
||||
"messageformat": "Moon icon",
|
||||
"description": "Screenreader description for the moon icon used to signify notification profiles"
|
||||
@@ -7632,6 +7744,22 @@
|
||||
"messageformat": "Done",
|
||||
"description": "Button to dismiss the backup key viewer, shown when reviewing your backup key for local on-device backups."
|
||||
},
|
||||
"icu:Preferences--local-backups--view-backup-key--os-prompt--mac": {
|
||||
"messageformat": "show your backup key",
|
||||
"description": "Shown as part of an OS prompt with a final string like 'Signal Desktop is trying to show your backup key'"
|
||||
},
|
||||
"icu:Preferences--local-backups--view-backup-key--os-prompt--windows": {
|
||||
"messageformat": "Verify your identity to view your backup key.",
|
||||
"description": "Shown as the body text of an OS prompt with a title like \"Making sure it's you\""
|
||||
},
|
||||
"icu:Preferences--local-backups--view-backup-key--os-prompt-description--linux": {
|
||||
"messageformat": "View backup key",
|
||||
"description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog header the user will see on the polkit prompt before showing the key"
|
||||
},
|
||||
"icu:Preferences--local-backups--view-backup-key--os-prompt-message--linux": {
|
||||
"messageformat": "Authentication is required to view your backup key.",
|
||||
"description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog text the user will see on the polkit prompt before showing the key"
|
||||
},
|
||||
"icu:Preferences--local-backups-backup-key-text-box": {
|
||||
"messageformat": "Backup key text box",
|
||||
"description": "ARIA label for the text box used to view or confirm the backup key for local message backups."
|
||||
@@ -7688,6 +7816,22 @@
|
||||
"messageformat": "Backup key copied",
|
||||
"description": "Toast message after you copied the backup key to clipboard from settings for local on-device backups"
|
||||
},
|
||||
"icu:Preferences__local-backups--enable--os-prompt--mac": {
|
||||
"messageformat": "enable backups",
|
||||
"description": "Shown as part of an OS prompt with a final string like 'Signal Desktop is trying to enable backups'"
|
||||
},
|
||||
"icu:Preferences__local-backups--enable--os-prompt--windows": {
|
||||
"messageformat": "Verify your identity to enable backups.",
|
||||
"description": "Shown as the body text of an OS prompt with a title like \"Making sure it's you\""
|
||||
},
|
||||
"icu:Preferences__local-backups--enable--os-prompt-description--linux": {
|
||||
"messageformat": "Enable backups",
|
||||
"description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog header the user will see on the polkit prompt before enabling backups"
|
||||
},
|
||||
"icu:Preferences__local-backups--enable--os-prompt-message--linux": {
|
||||
"messageformat": "Authentication is required to enable backups.",
|
||||
"description": "If linux system has pkcheck and we were able to install .policy files, this is the dialog text the user will see on the polkit prompt before enabling backups"
|
||||
},
|
||||
"icu:Preferences__view-key": {
|
||||
"messageformat": "View key",
|
||||
"description": "Button to view the backup key which is used to restore a message history backup"
|
||||
|
||||
@@ -2807,6 +2807,7 @@ ipc.on('get-config', async event => {
|
||||
|
||||
// paths
|
||||
crashDumpsPath: app.getPath('crashDumps'),
|
||||
downloadsPath: app.getPath('downloads'),
|
||||
homePath: app.getPath('home'),
|
||||
installPath: app.getAppPath(),
|
||||
userDataPath: app.getPath('userData'),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<vendor>Signal Desktop</vendor>
|
||||
<vendor_url>https://signal.org/</vendor_url>
|
||||
<action id="org.signalapp.enable-backups">
|
||||
<description>Enable backups</description>
|
||||
<message>Authentication is required to enable backups.</message>
|
||||
<!-- <description>{description}</description> -->
|
||||
<!-- <message>{message}</message> -->
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
16
build/policy-templates/org.signalapp.plaintext-export.policy
Normal file
16
build/policy-templates/org.signalapp.plaintext-export.policy
Normal 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>
|
||||
@@ -4,8 +4,8 @@
|
||||
<vendor>Signal Desktop</vendor>
|
||||
<vendor_url>https://signal.org/</vendor_url>
|
||||
<action id="org.signalapp.view-aep">
|
||||
<description>View backup key</description>
|
||||
<message>Authentication is required to view your backup key.</message>
|
||||
<!-- <description>{description}</description> -->
|
||||
<!-- <message>{message}</message> -->
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
@@ -20,7 +20,7 @@
|
||||
"start": "electron .",
|
||||
"generate": "run-s generate:phase-0 generate:phase-1",
|
||||
"generate:phase-0": "run-p build:esbuild:scripts",
|
||||
"generate:phase-1": "run-p --aggregate-output --print-label generate:phase-1:bundle build:icu-types build:compact-locales build:styles get-expire-time copy-components",
|
||||
"generate:phase-1": "run-p --aggregate-output --print-label generate:phase-1:bundle build:icu-types build:compact-locales build:styles get-expire-time copy-components build:policy-files",
|
||||
"generate:phase-1:bundle": "run-s build-protobuf build:esbuild:bundle",
|
||||
"build-release": "pnpm run build",
|
||||
"sign-release": "node ts/updater/generateSignature.js",
|
||||
@@ -83,7 +83,7 @@
|
||||
"test:storybook:test": "wait-on http://127.0.0.1:6006/ --timeout 5000 && test-storybook --testTimeout 60000",
|
||||
"build": "run-s --print-label generate build:esbuild:prod build:release",
|
||||
"build-win32-all": "run-s --print-label generate build:esbuild:prod build:release-win32-all",
|
||||
"build-linux": "run-s generate build:esbuild:prod && pnpm run build:release --publish=never",
|
||||
"build-linux": "run-s build:policy-files generate build:esbuild:prod && pnpm run build:release --publish=never",
|
||||
"build:acknowledgments": "node scripts/generate-acknowledgments.js",
|
||||
"build:dns-fallback": "node ts/scripts/generate-dns-fallback.node.js",
|
||||
"build:icu-types": "node ts/scripts/generate-icu-types.node.js",
|
||||
@@ -94,6 +94,7 @@
|
||||
"build:esbuild:scripts": "node scripts/esbuild.js --no-bundle",
|
||||
"build:esbuild:bundle": "node scripts/esbuild.js --no-scripts",
|
||||
"build:esbuild:prod": "node scripts/esbuild.js --prod",
|
||||
"build:policy-files": "node ts/scripts/gen-policy-files.node.js",
|
||||
"build:styles": "pnpm run \"/^build:styles:.*/\"",
|
||||
"build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7",
|
||||
"build:styles:tailwind": "tailwindcss -i ./stylesheets/tailwind-config.css -o ./stylesheets/tailwind.css",
|
||||
@@ -132,7 +133,7 @@
|
||||
"@react-aria/utils": "3.25.3",
|
||||
"@react-spring/web": "9.7.5",
|
||||
"@react-types/shared": "3.27.0",
|
||||
"@signalapp/libsignal-client": "0.83.0",
|
||||
"@signalapp/libsignal-client": "0.86.3",
|
||||
"@signalapp/minimask": "1.0.1",
|
||||
"@signalapp/mute-state-change": "workspace:1.0.0",
|
||||
"@signalapp/quill-cjs": "2.1.2",
|
||||
|
||||
@@ -11,10 +11,10 @@ index 47e6f48fcbed88b6ac07cff15c888c1b8b59721f..76dd6cc7265054222f2d70c76aa8456d
|
||||
productFilename: packager.appInfo.productFilename,
|
||||
...packager.platformSpecificBuildOptions,
|
||||
diff --git a/templates/linux/after-install.tpl b/templates/linux/after-install.tpl
|
||||
index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d839ebdf8f8 100644
|
||||
index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a3cb08a6dc7970ab2b32c731f41ea6e471204a19 100644
|
||||
--- a/templates/linux/after-install.tpl
|
||||
+++ b/templates/linux/after-install.tpl
|
||||
@@ -55,3 +55,24 @@ if apparmor_status --enabled > /dev/null 2>&1; then
|
||||
@@ -55,3 +55,26 @@ if apparmor_status --enabled > /dev/null 2>&1; then
|
||||
echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile"
|
||||
fi
|
||||
fi
|
||||
@@ -27,10 +27,12 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83
|
||||
+ POLICY_ORG='org.signalapp'
|
||||
+ POLICY_ENABLE_BACKUPS='enable-backups.policy'
|
||||
+ POLICY_VIEW_AEP='view-aep.policy'
|
||||
+ POLICY_EXPORT='plaintext-export.policy'
|
||||
+ mkdir -p "$POLICY_TARGET_PATH";
|
||||
+ # Separate policies for staging and production builds
|
||||
+ cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_ENABLE_BACKUPS" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_ENABLE_BACKUPS"
|
||||
+ cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_VIEW_AEP" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_VIEW_AEP"
|
||||
+ cp -f "$POLICY_SOURCE_PATH/$POLICY_ORG.$POLICY_EXPORT" "$POLICY_TARGET_PATH/$POLICY_ORG.${sanitizedName}.$POLICY_EXPORT"
|
||||
+else
|
||||
+ echo "Skipping installation of policies as polkit does not seem to be installed. This may affect the availability of some features.";
|
||||
+fi
|
||||
@@ -40,7 +42,7 @@ index 6cf860bd2847bae35ca8885cb680dd6c8c516e39..a19f9610d7101c925bdad8a88c434d83
|
||||
+
|
||||
+# SIGNAL CHANGES END
|
||||
diff --git a/templates/linux/after-remove.tpl b/templates/linux/after-remove.tpl
|
||||
index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..b5011d1b8cdb741ba6453f942a3c0660b66d41a0 100644
|
||||
index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..676e76bae609cd3309be87a4606346f75945e900 100644
|
||||
--- a/templates/linux/after-remove.tpl
|
||||
+++ b/templates/linux/after-remove.tpl
|
||||
@@ -13,3 +13,12 @@ APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'
|
||||
@@ -56,7 +58,6 @@ index 19b3decabe18a816f9ed5440fa9124ebfd6e3907..b5011d1b8cdb741ba6453f942a3c0660
|
||||
+fi
|
||||
+
|
||||
+# SIGNAL CHANGES END
|
||||
\ No newline at end of file
|
||||
diff --git a/templates/nsis/include/installer.nsh b/templates/nsis/include/installer.nsh
|
||||
index 34e91dfe82fdbb2e929820f2e8deb771b7f7893c..73bfffc6c227a018cbbeb690d6d7b882ed142fc8 100644
|
||||
--- a/templates/nsis/include/installer.nsh
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -29,7 +29,7 @@ patchedDependencies:
|
||||
hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495
|
||||
path: patches/@vitest+expect+2.0.5.patch
|
||||
app-builder-lib:
|
||||
hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420
|
||||
hash: a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664
|
||||
path: patches/app-builder-lib.patch
|
||||
casual@1.6.2:
|
||||
hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599
|
||||
@@ -124,8 +124,8 @@ importers:
|
||||
specifier: 3.27.0
|
||||
version: 3.27.0(react@18.3.1)
|
||||
'@signalapp/libsignal-client':
|
||||
specifier: 0.83.0
|
||||
version: 0.83.0
|
||||
specifier: 0.86.3
|
||||
version: 0.86.3
|
||||
'@signalapp/minimask':
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
@@ -3487,8 +3487,8 @@ packages:
|
||||
'@signalapp/libsignal-client@0.76.7':
|
||||
resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==}
|
||||
|
||||
'@signalapp/libsignal-client@0.83.0':
|
||||
resolution: {integrity: sha512-QaXviPAvj4PA2QDmN6YyPnlkp699BE3fIgaJmKrfvZMsvBfMGeJ3H3BHFt0CV2vUWMbc3oEgxbwdXu//f6oTrA==}
|
||||
'@signalapp/libsignal-client@0.86.3':
|
||||
resolution: {integrity: sha512-aN/pgT9YqacuABrtxBtBbQ0AMesZJIHVNqU8nUq75kRTleIU5aKeuOXt7ZHYUUJW7ot4O2n6O6eaMnMLbwBXFQ==}
|
||||
|
||||
'@signalapp/minimask@1.0.1':
|
||||
resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==}
|
||||
@@ -14281,7 +14281,7 @@ snapshots:
|
||||
type-fest: 4.26.1
|
||||
uuid: 11.0.2
|
||||
|
||||
'@signalapp/libsignal-client@0.83.0':
|
||||
'@signalapp/libsignal-client@0.86.3':
|
||||
dependencies:
|
||||
node-gyp-build: 4.8.4
|
||||
type-fest: 4.26.1
|
||||
@@ -15660,7 +15660,7 @@ snapshots:
|
||||
|
||||
app-builder-bin@5.0.0-alpha.12: {}
|
||||
|
||||
app-builder-lib@26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14):
|
||||
app-builder-lib@26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14):
|
||||
dependencies:
|
||||
'@develar/schema-utils': 2.6.5
|
||||
'@electron/asar': 3.4.1
|
||||
@@ -16879,7 +16879,7 @@ snapshots:
|
||||
|
||||
dmg-builder@26.0.14(patch_hash=cb72ed47fa8d45513a36db33fcb41cb75c30cada4737da067bf3fa1f063725f2)(electron-builder-squirrel-windows@26.0.14):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
builder-util: 26.0.13
|
||||
builder-util-runtime: 9.3.2
|
||||
fs-extra: 10.1.0
|
||||
@@ -17025,7 +17025,7 @@ snapshots:
|
||||
|
||||
electron-builder-squirrel-windows@26.0.14(dmg-builder@26.0.14):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
builder-util: 26.0.13
|
||||
electron-winstaller: 5.4.0
|
||||
transitivePeerDependencies:
|
||||
@@ -17035,7 +17035,7 @@ snapshots:
|
||||
|
||||
electron-builder@26.0.14(electron-builder-squirrel-windows@26.0.14):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.14(patch_hash=b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
app-builder-lib: 26.0.14(patch_hash=a1775a435732fdbd3b69305053bea4776c854378984940cbd2a541d692902664)(dmg-builder@26.0.14)(electron-builder-squirrel-windows@26.0.14)
|
||||
builder-util: 26.0.13
|
||||
builder-util-runtime: 9.3.2
|
||||
chalk: 4.1.2
|
||||
|
||||
@@ -5,6 +5,7 @@ syntax = "proto3";
|
||||
package signalbackups;
|
||||
|
||||
option java_package = "org.thoughtcrime.securesms.backup.v2.proto";
|
||||
option swift_prefix = "BackupProto_";
|
||||
|
||||
message BackupInfo {
|
||||
uint64 version = 1;
|
||||
@@ -12,6 +13,7 @@ message BackupInfo {
|
||||
bytes mediaRootBackupKey = 3; // 32-byte random value generated when the backup is uploaded for the first time.
|
||||
string currentAppVersion = 4;
|
||||
string firstAppVersion = 5;
|
||||
bytes debugInfo = 6; // Client-specific data field for debug info during testing
|
||||
}
|
||||
|
||||
// Frames must follow in the following ordering rules:
|
||||
@@ -68,6 +70,26 @@ message AccountData {
|
||||
Color color = 3;
|
||||
}
|
||||
|
||||
enum SentMediaQuality {
|
||||
UNKNOWN_QUALITY = 0; // Interpret as "Standard"
|
||||
STANDARD = 1;
|
||||
HIGH = 2;
|
||||
}
|
||||
|
||||
message AutoDownloadSettings {
|
||||
enum AutoDownloadOption {
|
||||
UNKNOWN = 0; // Interpret as "Never"
|
||||
NEVER = 1;
|
||||
WIFI = 2;
|
||||
WIFI_AND_CELLULAR = 3;
|
||||
}
|
||||
|
||||
AutoDownloadOption images = 1;
|
||||
AutoDownloadOption audio = 2;
|
||||
AutoDownloadOption video = 3;
|
||||
AutoDownloadOption documents = 4;
|
||||
}
|
||||
|
||||
message AccountSettings {
|
||||
bool readReceipts = 1;
|
||||
bool sealedSenderIndicators = 2;
|
||||
@@ -91,6 +113,12 @@ message AccountData {
|
||||
bool optimizeOnDeviceStorage = 20;
|
||||
// See zkgroup for integer particular values. Unset if backups are not enabled.
|
||||
optional uint64 backupTier = 21;
|
||||
reserved /* showSealedSenderIndicators */ 22;
|
||||
SentMediaQuality defaultSentMediaQuality = 23;
|
||||
AutoDownloadSettings autoDownloadSettings = 24;
|
||||
reserved /* wifiAutoDownloadSettings */ 25;
|
||||
optional uint32 screenLockTimeoutMinutes = 26; // If unset, consider screen lock to be disabled.
|
||||
optional bool pinReminders = 27; // If unset, consider pin reminders to be enabled.
|
||||
}
|
||||
|
||||
message SubscriberData {
|
||||
@@ -111,6 +139,11 @@ message AccountData {
|
||||
}
|
||||
}
|
||||
|
||||
message AndroidSpecificSettings {
|
||||
bool useSystemEmoji = 1;
|
||||
bool screenshotSecurity = 2;
|
||||
}
|
||||
|
||||
bytes profileKey = 1;
|
||||
optional string username = 2;
|
||||
UsernameLink usernameLink = 3;
|
||||
@@ -122,6 +155,9 @@ message AccountData {
|
||||
AccountSettings accountSettings = 9;
|
||||
IAPSubscriberData backupsSubscriberData = 10;
|
||||
string svrPin = 11;
|
||||
AndroidSpecificSettings androidSpecificSettings = 12;
|
||||
string bioText = 13;
|
||||
string bioEmoji = 14;
|
||||
}
|
||||
|
||||
message Recipient {
|
||||
@@ -344,7 +380,7 @@ message CallLink {
|
||||
string name = 3;
|
||||
Restrictions restrictions = 4;
|
||||
uint64 expirationMs = 5;
|
||||
optional bytes epoch = 6;
|
||||
optional bytes epoch = 6; // May be absent/empty for older links
|
||||
}
|
||||
|
||||
message AdHocCall {
|
||||
@@ -695,7 +731,6 @@ message MessageAttachment {
|
||||
}
|
||||
|
||||
message FilePointer {
|
||||
|
||||
message LocatorInfo {
|
||||
// Must be non-empty if transitCdnKey or plaintextHash are set/nonempty.
|
||||
// Otherwise must be empty.
|
||||
@@ -828,11 +863,6 @@ message Poll {
|
||||
repeated Reaction reactions = 5;
|
||||
}
|
||||
|
||||
message PollTerminateUpdate {
|
||||
uint64 targetSentTimestamp = 1;
|
||||
string question = 2; // Between 1-100 characters
|
||||
}
|
||||
|
||||
message ChatUpdateMessage {
|
||||
// If unset, importers should ignore the update message without throwing an error.
|
||||
oneof update {
|
||||
@@ -1151,7 +1181,7 @@ message GroupJoinRequestCanceledUpdate {
|
||||
bytes requestorAci = 1;
|
||||
}
|
||||
|
||||
// A single requestor has requested to join and canceled
|
||||
// A single requestor has requested to join and cancelled
|
||||
// their request repeatedly with no other updates in between.
|
||||
// The last action encompassed by this update is always a
|
||||
// cancellation; if there was another open request immediately
|
||||
@@ -1211,6 +1241,11 @@ message GroupExpirationTimerUpdate {
|
||||
optional bytes updaterAci = 2;
|
||||
}
|
||||
|
||||
message PollTerminateUpdate {
|
||||
uint64 targetSentTimestamp = 1;
|
||||
string question = 2; // Between 1-100 characters
|
||||
}
|
||||
|
||||
message StickerPack {
|
||||
bytes packId = 1;
|
||||
bytes packKey = 2;
|
||||
|
||||
@@ -201,8 +201,13 @@ export function getCI({
|
||||
}
|
||||
|
||||
async function exportLocalBackup(backupsBaseDir: string): Promise<string> {
|
||||
const { snapshotDir } =
|
||||
await backupsService.exportLocalBackup(backupsBaseDir);
|
||||
const { snapshotDir } = await backupsService.exportLocalBackup(
|
||||
backupsBaseDir,
|
||||
{
|
||||
type: 'local-encrypted',
|
||||
localBackupSnapshotDir: backupsBaseDir,
|
||||
}
|
||||
);
|
||||
return snapshotDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import lodash from 'lodash';
|
||||
import semver from 'semver';
|
||||
|
||||
import type { getConfig } from './textsecure/WebAPI.preload.js';
|
||||
import { createLogger } from './logging/log.std.js';
|
||||
@@ -14,12 +15,21 @@ import { HashType } from './types/Crypto.std.js';
|
||||
import { getCountryCode } from './types/PhoneNumber.std.js';
|
||||
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration.dom.js';
|
||||
import type { StorageInterface } from './types/Storage.d.ts';
|
||||
import { ToastType } from './types/Toast.dom.js';
|
||||
|
||||
const { get, throttle } = lodash;
|
||||
|
||||
const log = createLogger('RemoteConfig');
|
||||
|
||||
const KnownConfigKeys = [
|
||||
// Semver flags must always be set to a valid semver (no empty enabled-only keys)
|
||||
const SemverKeys = [
|
||||
'desktop.plaintextExport.beta',
|
||||
'desktop.plaintextExport.prod',
|
||||
] as const;
|
||||
|
||||
export type SemverKeyType = (typeof SemverKeys)[number];
|
||||
|
||||
const ScalarKeys = [
|
||||
'desktop.chatFolders.alpha',
|
||||
'desktop.chatFolders.beta',
|
||||
'desktop.chatFolders.prod',
|
||||
@@ -56,6 +66,8 @@ const KnownConfigKeys = [
|
||||
'global.textAttachmentLimitBytes',
|
||||
] as const;
|
||||
|
||||
const KnownConfigKeys = [...SemverKeys, ...ScalarKeys] as const;
|
||||
|
||||
export type ConfigKeyType = (typeof KnownConfigKeys)[number];
|
||||
|
||||
type ConfigValueType = {
|
||||
@@ -139,6 +151,7 @@ export const _refreshRemoteConfig = async ({
|
||||
}
|
||||
|
||||
const oldConfig = config;
|
||||
let semverError = false;
|
||||
config = Array.from(newConfigValues.entries()).reduce(
|
||||
(acc, [name, value]) => {
|
||||
const enabled = value !== undefined && value.toLowerCase() !== 'false';
|
||||
@@ -169,6 +182,17 @@ export const _refreshRemoteConfig = async ({
|
||||
const hasChanged =
|
||||
previouslyEnabled !== enabled || previousValue !== configValue.value;
|
||||
|
||||
if (
|
||||
SemverKeys.includes(configValue.name as SemverKeyType) &&
|
||||
configValue.enabled &&
|
||||
(!configValue.value || !semver.parse(configValue.value))
|
||||
) {
|
||||
log.error(
|
||||
`Key ${name} had invalid semver value '${configValue.value}'`
|
||||
);
|
||||
semverError = true;
|
||||
}
|
||||
|
||||
// If enablement changes at all, notify listeners
|
||||
const currentListeners = listeners[name] || [];
|
||||
if (hasChanged) {
|
||||
@@ -187,6 +211,12 @@ export const _refreshRemoteConfig = async ({
|
||||
{}
|
||||
);
|
||||
|
||||
if (semverError && config['desktop.internalUser']?.enabled) {
|
||||
window.reduxActions.toast.showToast({
|
||||
toastType: ToastType.Error,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteExpirationValue = getValue('desktop.clientExpiration');
|
||||
if (!remoteExpirationValue) {
|
||||
// If remote configuration fetch worked - we are not expired anymore.
|
||||
|
||||
@@ -215,7 +215,7 @@ export namespace AxoAlertDialog {
|
||||
* ----------------------------------
|
||||
*/
|
||||
|
||||
export type ActionVariant = 'primary' | 'destructive';
|
||||
export type ActionVariant = 'primary' | 'secondary' | 'destructive';
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
variant: ActionVariant;
|
||||
|
||||
@@ -363,6 +363,7 @@ export namespace AxoDialog {
|
||||
variant: ActionVariant;
|
||||
symbol?: AxoSymbol.InlineGlyphName;
|
||||
arrow?: boolean;
|
||||
experimentalSpinner?: { 'aria-label': string } | null;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
@@ -373,9 +374,10 @@ export namespace AxoDialog {
|
||||
variant={props.variant}
|
||||
symbol={props.symbol}
|
||||
arrow={props.arrow}
|
||||
experimentalSpinner={props.experimentalSpinner}
|
||||
onClick={props.onClick}
|
||||
size="md"
|
||||
width="grow"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</AxoButton.Root>
|
||||
|
||||
@@ -156,6 +156,9 @@ export type PropsType = {
|
||||
// LowDiskSpaceBackupImportModal
|
||||
lowDiskSpaceBackupImportModal: { bytesNeeded: number } | null;
|
||||
hideLowDiskSpaceBackupImportModal: () => void;
|
||||
// PlaintextExportWorkflow
|
||||
shouldShowPlaintextExportWorkflow: boolean;
|
||||
renderPlaintextExportWorkflow: () => JSX.Element;
|
||||
};
|
||||
|
||||
export function GlobalModalContainer({
|
||||
@@ -255,13 +258,21 @@ export function GlobalModalContainer({
|
||||
// LowDiskSpaceBackupImportModal
|
||||
lowDiskSpaceBackupImportModal,
|
||||
hideLowDiskSpaceBackupImportModal,
|
||||
// PlaintextExportWorkflow
|
||||
shouldShowPlaintextExportWorkflow,
|
||||
renderPlaintextExportWorkflow,
|
||||
}: PropsType): JSX.Element | null {
|
||||
// We want the following dialogs to show in this order:
|
||||
// 0. Stateful multi-modal workflows
|
||||
// 1. Errors
|
||||
// 2. Safety Number Changes
|
||||
// 3. Forward Modal, so other modals can open it
|
||||
// 4. The Rest (in no particular order, but they're ordered alphabetically)
|
||||
|
||||
if (shouldShowPlaintextExportWorkflow) {
|
||||
return renderPlaintextExportWorkflow();
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (errorModalProps) {
|
||||
return renderErrorModal(errorModalProps);
|
||||
|
||||
175
ts/components/PlaintextExportWorkflow.dom.stories.tsx
Normal file
175
ts/components/PlaintextExportWorkflow.dom.stories.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
306
ts/components/PlaintextExportWorkflow.dom.tsx
Normal file
306
ts/components/PlaintextExportWorkflow.dom.tsx
Normal 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);
|
||||
}
|
||||
@@ -117,6 +117,7 @@ const availableSpeakers = [
|
||||
];
|
||||
|
||||
const validateBackupResult: ExportResultType = {
|
||||
attachmentBackupJobs: [],
|
||||
totalBytes: 100,
|
||||
duration: 10000,
|
||||
stats: {
|
||||
@@ -137,6 +138,7 @@ const validateBackupResult: ExportResultType = {
|
||||
const exportLocalBackupResult: LocalBackupExportResultType = {
|
||||
...validateBackupResult,
|
||||
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
|
||||
totalAttachmentBytes: 1000000,
|
||||
};
|
||||
|
||||
const donationAmountsConfig = {
|
||||
@@ -466,6 +468,7 @@ export default {
|
||||
isContentProtectionSupported: true,
|
||||
isContentProtectionNeeded: true,
|
||||
isMinimizeToAndStartInSystemTraySupported: true,
|
||||
isPlaintextExportEnabled: true,
|
||||
lastSyncTime: Date.now(),
|
||||
localeOverride: null,
|
||||
localBackupFolder: undefined,
|
||||
@@ -614,6 +617,7 @@ export default {
|
||||
),
|
||||
setSettingsLocation: action('setSettingsLocation'),
|
||||
showToast: action('showToast'),
|
||||
startPlaintextExport: action('startPlaintextExport'),
|
||||
validateBackup: async () => {
|
||||
return {
|
||||
result: validateBackupResult,
|
||||
@@ -703,7 +707,13 @@ export const Donations = Template.bind({});
|
||||
Donations.args = {
|
||||
settingsLocation: { page: SettingsPage.Donations },
|
||||
};
|
||||
|
||||
export const ChatsWithDisabledPlaintextExport = Template.bind({});
|
||||
ChatsWithDisabledPlaintextExport.args = {
|
||||
settingsLocation: {
|
||||
page: SettingsPage.Chats,
|
||||
},
|
||||
isPlaintextExportEnabled: false,
|
||||
};
|
||||
export const NotificationsPageWithThreeProfiles = Template.bind({});
|
||||
const threeProfiles = [
|
||||
{
|
||||
|
||||
@@ -192,6 +192,7 @@ export type PropsDataType = {
|
||||
isContentProtectionSupported: boolean;
|
||||
isHideMenuBarSupported: boolean;
|
||||
isNotificationAttentionSupported: boolean;
|
||||
isPlaintextExportEnabled: boolean;
|
||||
isSyncSupported: boolean;
|
||||
isSystemTraySupported: boolean;
|
||||
isMinimizeToAndStartInSystemTraySupported: boolean;
|
||||
@@ -269,6 +270,7 @@ type PropsFunctionType = {
|
||||
) => unknown;
|
||||
setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
|
||||
showToast: (toast: AnyToast) => unknown;
|
||||
startPlaintextExport: () => unknown;
|
||||
validateBackup: () => Promise<BackupValidationResultType>;
|
||||
|
||||
internalAddDonationReceipt: (receipt: DonationReceipt) => void;
|
||||
@@ -439,6 +441,7 @@ export function Preferences({
|
||||
isContentProtectionSupported,
|
||||
isHideMenuBarSupported,
|
||||
isNotificationAttentionSupported,
|
||||
isPlaintextExportEnabled,
|
||||
isSyncSupported,
|
||||
isSystemTraySupported,
|
||||
isMinimizeToAndStartInSystemTraySupported,
|
||||
@@ -519,6 +522,7 @@ export function Preferences({
|
||||
setSettingsLocation,
|
||||
shouldShowUpdateDialog,
|
||||
showToast,
|
||||
startPlaintextExport,
|
||||
localeOverride,
|
||||
theme,
|
||||
themeSetting,
|
||||
@@ -1232,6 +1236,34 @@ export function Preferences({
|
||||
</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 && (
|
||||
<SettingsRow>
|
||||
<Control
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
ScheduleDays,
|
||||
} from '../types/NotificationProfile.std.js';
|
||||
import type { SettingsLocation } from '../types/Nav.std.js';
|
||||
import { addLeadingZero } from '../util/timestamp.std.js';
|
||||
|
||||
enum CreateFlowPage {
|
||||
Name = 'Name',
|
||||
@@ -163,12 +164,6 @@ function formatTimeForInput(time: number): Time {
|
||||
const { hours, minutes } = getTimeDetails(time, true);
|
||||
return new Time(hours, minutes);
|
||||
}
|
||||
function addLeadingZero(minutes: number): string {
|
||||
if (minutes < 10) {
|
||||
return `0${minutes}`;
|
||||
}
|
||||
return minutes.toString();
|
||||
}
|
||||
|
||||
function parseTimeFromInput(time: Time): number {
|
||||
return time.hour * 100 + time.minute;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// 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 {
|
||||
constants as FS_CONSTANTS,
|
||||
copyFile,
|
||||
@@ -10,222 +10,53 @@ import {
|
||||
rename,
|
||||
} from 'node:fs/promises';
|
||||
|
||||
import * as durations from '../util/durations/index.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 {
|
||||
getAbsoluteAttachmentPath,
|
||||
getAbsoluteAttachmentPath as doGetAbsoluteAttachmentPath,
|
||||
getAbsoluteTempPath,
|
||||
getAbsoluteTempPath as doGetAbsoluteTempPath,
|
||||
} 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 {
|
||||
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 {
|
||||
getLocalBackupDirectoryForMediaName,
|
||||
getLocalBackupPathForMediaName,
|
||||
} from '../services/backups/util/localBackup.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 MAX_CONCURRENT_JOBS = 3;
|
||||
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 {}
|
||||
export class AttachmentPermanentlyMissingError extends Error {}
|
||||
|
||||
type RunAttachmentBackupJobDependenciesType = {
|
||||
getAbsoluteAttachmentPath: typeof doGetAbsoluteAttachmentPath;
|
||||
backupMediaBatch?: typeof doBackupMediaBatch;
|
||||
backupsService: BackupsService;
|
||||
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
|
||||
getAbsoluteTempPath: typeof doGetAbsoluteAttachmentPath;
|
||||
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
|
||||
};
|
||||
|
||||
export async function runAttachmentBackupJob(
|
||||
job: AttachmentLocalBackupJobType,
|
||||
_options: {
|
||||
isLastAttempt: boolean;
|
||||
abortSignal: AbortSignal;
|
||||
},
|
||||
dependencies: RunAttachmentBackupJobDependenciesType = {
|
||||
getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath,
|
||||
backupsService,
|
||||
backupMediaBatch: doBackupMediaBatch,
|
||||
encryptAndUploadAttachment,
|
||||
decryptAttachmentV2ToSink,
|
||||
}
|
||||
): Promise<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' };
|
||||
}
|
||||
export function getJobIdForLogging(
|
||||
job: CoreAttachmentLocalBackupJobType
|
||||
): string {
|
||||
return `${redactGenericText(job.mediaName)}`;
|
||||
}
|
||||
|
||||
async function runAttachmentBackupJobInner(
|
||||
job: AttachmentLocalBackupJobType,
|
||||
dependencies: RunAttachmentBackupJobDependenciesType
|
||||
export async function runAttachmentBackupJob(
|
||||
job: CoreAttachmentLocalBackupJobType,
|
||||
backupsBaseDir: string,
|
||||
dependencies: RunAttachmentBackupJobDependenciesType = {
|
||||
getAbsoluteAttachmentPath: doGetAbsoluteAttachmentPath,
|
||||
getAbsoluteTempPath: doGetAbsoluteTempPath,
|
||||
decryptAttachmentV2ToSink,
|
||||
}
|
||||
): Promise<void> {
|
||||
const jobIdForLogging = getJobIdForLogging(job);
|
||||
const logId = `AttachmentLocalBackupManager.runAttachmentBackupJobInner(${jobIdForLogging})`;
|
||||
const logId = `AttachmentLocalBackupManager.runAttachmentBackupJob(${jobIdForLogging})`;
|
||||
|
||||
log.info(`${logId}: starting`);
|
||||
log.info(`${logId}: starting...`);
|
||||
|
||||
const { backupsBaseDir, mediaName } = job;
|
||||
const { path } = job.data;
|
||||
const { isPlaintextExport, mediaName } = job;
|
||||
const { contentType, fileName, localKey, path, size } = job.data;
|
||||
|
||||
if (!path) {
|
||||
throw new AttachmentPermanentlyMissingError('No path property');
|
||||
@@ -240,22 +71,93 @@ async function runAttachmentBackupJobInner(
|
||||
backupsBaseDir,
|
||||
mediaName,
|
||||
});
|
||||
|
||||
await mkdir(localBackupFileDir, { recursive: true });
|
||||
|
||||
const sourceAttachmentPath = dependencies.getAbsoluteAttachmentPath(path);
|
||||
const destinationLocalBackupFilePath = getLocalBackupPathForMediaName({
|
||||
backupsBaseDir,
|
||||
mediaName,
|
||||
});
|
||||
|
||||
// File is already encrypted with localKey, so we just have to copy it to the backup dir
|
||||
const sourceAttachmentPath = getAbsoluteAttachmentPath(path);
|
||||
const tempPath = getAbsoluteTempPath(createName());
|
||||
if (isPlaintextExport) {
|
||||
const extension = getExtension(contentType, fileName);
|
||||
const outPath = extension
|
||||
? `${destinationLocalBackupFilePath}.${extension}`
|
||||
: destinationLocalBackupFilePath;
|
||||
const outFileStream = createWriteStream(outPath);
|
||||
await dependencies.decryptAttachmentV2ToSink(
|
||||
{
|
||||
ciphertextPath: sourceAttachmentPath,
|
||||
idForLogging: 'AttachmentLocalBackupManager',
|
||||
keysBase64: localKey,
|
||||
size,
|
||||
type: 'local',
|
||||
},
|
||||
outFileStream
|
||||
);
|
||||
} else {
|
||||
// File is already encrypted with localKey, so we just copy it to the backup dir
|
||||
const tempPath = dependencies.getAbsoluteTempPath(createName());
|
||||
|
||||
// A unique constraint on the DB table should enforce that only one job is writing to
|
||||
// the same mediaName at a time, but just to be safe, we copy to temp file and rename to
|
||||
// ensure the atomicity of the copy operation
|
||||
// the same mediaName at a time, but just to be safe, we copy to temp file and rename
|
||||
// to ensure the atomicity of the copy operation
|
||||
|
||||
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy)
|
||||
await copyFile(sourceAttachmentPath, tempPath, FS_CONSTANTS.COPYFILE_FICLONE);
|
||||
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, graceful fallback to copy)
|
||||
await copyFile(
|
||||
sourceAttachmentPath,
|
||||
tempPath,
|
||||
FS_CONSTANTS.COPYFILE_FICLONE
|
||||
);
|
||||
await rename(tempPath, destinationLocalBackupFilePath);
|
||||
}
|
||||
|
||||
log.info(`${logId}: complete!`);
|
||||
}
|
||||
|
||||
function getExtension(
|
||||
contentType: string | undefined,
|
||||
fileName: string | undefined
|
||||
): string | undefined {
|
||||
if (fileName) {
|
||||
const extension = extname(fileName).replace(/^./, '');
|
||||
|
||||
if (extension) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (contentType.startsWith('application/x-')) {
|
||||
return contentType.replace('application/x-', '');
|
||||
}
|
||||
|
||||
if (contentType.startsWith('application/')) {
|
||||
return contentType.replace('application/', '');
|
||||
}
|
||||
|
||||
if (contentType.startsWith('audio/')) {
|
||||
return contentType.replace('audio/', '');
|
||||
}
|
||||
|
||||
if (contentType.startsWith('image/')) {
|
||||
return contentType.replace('image/', '');
|
||||
}
|
||||
|
||||
if (contentType === 'text/x-signal-plain') {
|
||||
return 'txt';
|
||||
}
|
||||
if (contentType.startsWith('text/x-')) {
|
||||
return contentType.replace('text/x-', '');
|
||||
}
|
||||
|
||||
if (contentType.startsWith('video/')) {
|
||||
return contentType.replace('video/', '');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
110
ts/scripts/gen-policy-files.node.ts
Normal file
110
ts/scripts/gen-policy-files.node.ts
Normal 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);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import Long from 'long';
|
||||
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client';
|
||||
import { dirname } from 'node:path';
|
||||
import { BackupJsonExporter } from '@signalapp/libsignal-client/dist/MessageBackup.js';
|
||||
import pMap from 'p-map';
|
||||
import pTimeout from 'p-timeout';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -140,10 +140,9 @@ import { getFilePointerForAttachment } from './util/filePointers.preload.js';
|
||||
import { getBackupMediaRootKey } from './crypto.preload.js';
|
||||
import type {
|
||||
CoreAttachmentBackupJobType,
|
||||
PartialAttachmentLocalBackupJobType,
|
||||
CoreAttachmentLocalBackupJobType,
|
||||
} from '../../types/AttachmentBackup.std.js';
|
||||
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager.preload.js';
|
||||
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager.preload.js';
|
||||
import {
|
||||
getBackupCdnInfo,
|
||||
getLocalBackupFileNameForAttachment,
|
||||
@@ -196,6 +195,7 @@ const FLUSH_TIMEOUT = 30 * MINUTE;
|
||||
const REPORTING_THRESHOLD = SECOND;
|
||||
|
||||
const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE;
|
||||
const BACKUP_QUOTE_BODY_LIMIT = 2048;
|
||||
|
||||
type GetRecipientIdOptionsType =
|
||||
| Readonly<{
|
||||
@@ -270,11 +270,12 @@ export class BackupExportStream extends Readable {
|
||||
};
|
||||
#ourConversation?: ConversationAttributesType;
|
||||
#attachmentBackupJobs: Array<
|
||||
CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType
|
||||
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
|
||||
> = [];
|
||||
#buffers = new Array<Uint8Array>();
|
||||
#nextRecipientId = 1;
|
||||
#flushResolve: (() => void) | undefined;
|
||||
#jsonExporter: BackupJsonExporter | undefined;
|
||||
|
||||
// Map from custom color uuid to an index in accountSettings.customColors
|
||||
// array.
|
||||
@@ -289,7 +290,6 @@ export class BackupExportStream extends Readable {
|
||||
(async () => {
|
||||
log.info('BackupExportStream: starting...');
|
||||
drop(AttachmentBackupManager.stop());
|
||||
drop(AttachmentLocalBackupManager.stop());
|
||||
log.info('BackupExportStream: message migration starting...');
|
||||
await migrateAllMessages();
|
||||
|
||||
@@ -303,34 +303,6 @@ export class BackupExportStream extends Readable {
|
||||
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
|
||||
const { type } = this.options;
|
||||
switch (type) {
|
||||
case 'local-encrypted':
|
||||
{
|
||||
log.info(
|
||||
`BackupExportStream: Adding ${this.#attachmentBackupJobs.length} jobs for AttachmentLocalBackupManager`
|
||||
);
|
||||
const backupsBaseDir = dirname(
|
||||
this.options.localBackupSnapshotDir
|
||||
);
|
||||
|
||||
AttachmentLocalBackupManager.clearAllJobs();
|
||||
await Promise.all(
|
||||
this.#attachmentBackupJobs.map(job => {
|
||||
if (job.type !== 'local') {
|
||||
log.error(
|
||||
"BackupExportStream: Can't enqueue remote backup jobs during local backup, skipping"
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return AttachmentLocalBackupManager.addJob({
|
||||
...job,
|
||||
backupsBaseDir,
|
||||
});
|
||||
})
|
||||
);
|
||||
drop(AttachmentLocalBackupManager.start());
|
||||
}
|
||||
break;
|
||||
case 'remote':
|
||||
await DataWriter.clearAllAttachmentBackupJobs();
|
||||
await Promise.all(
|
||||
@@ -347,14 +319,21 @@ export class BackupExportStream extends Readable {
|
||||
);
|
||||
})
|
||||
);
|
||||
drop(AttachmentBackupManager.start());
|
||||
this.#attachmentBackupJobs = [];
|
||||
break;
|
||||
case 'plaintext-export':
|
||||
case 'local-encrypted':
|
||||
case 'cross-client-integration-test':
|
||||
log.info(
|
||||
`Type is ${this.options.type}, not doing anything with ${this.#attachmentBackupJobs.length} attachment jobs`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line no-unsafe-finally
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
drop(AttachmentBackupManager.start());
|
||||
log.info('BackupExportStream: finished');
|
||||
}
|
||||
})()
|
||||
@@ -368,18 +347,29 @@ export class BackupExportStream extends Readable {
|
||||
public getStats(): Readonly<StatsType> {
|
||||
return this.#stats;
|
||||
}
|
||||
public getAttachmentBackupJobs(): ReadonlyArray<
|
||||
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
|
||||
> {
|
||||
return this.#attachmentBackupJobs;
|
||||
}
|
||||
|
||||
async #unsafeRun(): Promise<void> {
|
||||
this.#ourConversation =
|
||||
window.ConversationController.getOurConversationOrThrow().attributes;
|
||||
this.push(
|
||||
Backups.BackupInfo.encodeDelimited({
|
||||
const backupInfo: Backups.IBackupInfo = {
|
||||
version: Long.fromNumber(BACKUP_VERSION),
|
||||
backupTimeMs: this.#backupTimeMs,
|
||||
mediaRootBackupKey: getBackupMediaRootKey().serialize(),
|
||||
firstAppVersion: itemStorage.get('restoredBackupFirstAppVersion'),
|
||||
currentAppVersion: `Desktop ${window.getVersion()}`,
|
||||
}).finish()
|
||||
};
|
||||
|
||||
this.push(
|
||||
this.#getJsonIfNeeded(
|
||||
this.options.type === 'plaintext-export'
|
||||
? Backups.BackupInfo.encode(backupInfo).finish()
|
||||
: Backups.BackupInfo.encodeDelimited(backupInfo).finish()
|
||||
)
|
||||
);
|
||||
|
||||
this.#pushFrame({
|
||||
@@ -779,6 +769,11 @@ export class BackupExportStream extends Readable {
|
||||
await pMap(
|
||||
messages,
|
||||
async message => {
|
||||
if (skippedConversationIds.has(message.conversationId)) {
|
||||
this.#stats.skippedMessages += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const chatItem = await this.#toChatItem(message, {
|
||||
aboutMe,
|
||||
callHistoryByCallId,
|
||||
@@ -817,6 +812,22 @@ export class BackupExportStream extends Readable {
|
||||
attachmentBackupJobs: this.#attachmentBackupJobs.length,
|
||||
});
|
||||
|
||||
if (this.#jsonExporter) {
|
||||
try {
|
||||
const result = this.#jsonExporter.finish();
|
||||
if (result?.errorMessage) {
|
||||
log.warn(
|
||||
'backups: jsonExporter.finish() returned validation error:',
|
||||
result.errorMessage
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// We only warn because this isn't that big of a deal - the export is complete.
|
||||
// All we need from the exporter at the end is any validation errors it found.
|
||||
log.warn('backups: jsonExporter returned error', toLogFormat(error));
|
||||
}
|
||||
}
|
||||
|
||||
this.push(null);
|
||||
}
|
||||
|
||||
@@ -824,8 +835,50 @@ export class BackupExportStream extends Readable {
|
||||
this.#buffers.push(buffer);
|
||||
}
|
||||
|
||||
#frameToJson(encodedFrame: Uint8Array): string {
|
||||
let lines = '';
|
||||
|
||||
if (!this.#jsonExporter) {
|
||||
const { exporter, chunk: initialChunk } = BackupJsonExporter.start(
|
||||
encodedFrame,
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
lines = `${initialChunk}\n`;
|
||||
this.#jsonExporter = exporter;
|
||||
} else {
|
||||
const results = this.#jsonExporter.exportFrames(encodedFrame);
|
||||
for (const result of results) {
|
||||
if (result.errorMessage) {
|
||||
log.warn(
|
||||
'frameToJson: frame had a validation error:',
|
||||
result.errorMessage
|
||||
);
|
||||
}
|
||||
if (!result.line) {
|
||||
log.error('frameToJson: frame was filtered out by libsignal');
|
||||
} else {
|
||||
lines += `${result.line}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
#getJsonIfNeeded(encoded: Uint8Array): Uint8Array {
|
||||
if (this.options.type === 'plaintext-export') {
|
||||
const json = this.#frameToJson(encoded);
|
||||
return Buffer.from(json, 'utf-8');
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
#pushFrame(frame: Backups.IFrame): void {
|
||||
this.#pushBuffer(Backups.Frame.encodeDelimited(frame).finish());
|
||||
const encodedFrame = Backups.Frame.encodeDelimited(frame).finish();
|
||||
const toPush = this.#getJsonIfNeeded(encodedFrame);
|
||||
this.#pushBuffer(toPush);
|
||||
}
|
||||
|
||||
async #flush(): Promise<void> {
|
||||
@@ -893,6 +946,9 @@ export class BackupExportStream extends Readable {
|
||||
|
||||
const backupsSubscriberData = generateBackupsSubscriberData();
|
||||
const backupTier = itemStorage.get('backupTier');
|
||||
const autoDownloadPrimary = itemStorage.get(
|
||||
'auto-download-attachment-primary'
|
||||
);
|
||||
|
||||
return {
|
||||
profileKey: itemStorage.get('profileKey'),
|
||||
@@ -921,6 +977,14 @@ export class BackupExportStream extends Readable {
|
||||
}
|
||||
: null,
|
||||
svrPin: itemStorage.get('svrPin'),
|
||||
bioText: me.get('about'),
|
||||
bioEmoji: me.get('aboutEmoji'),
|
||||
// Test only values
|
||||
...(isTestOrMockEnvironment()
|
||||
? {
|
||||
androidSpecificSettings: itemStorage.get('androidSpecificSettings'),
|
||||
}
|
||||
: {}),
|
||||
accountSettings: {
|
||||
readReceipts: itemStorage.get('read-receipt-setting'),
|
||||
sealedSenderIndicators: itemStorage.get('sealedSenderIndicators'),
|
||||
@@ -957,8 +1021,24 @@ export class BackupExportStream extends Readable {
|
||||
optimizeOnDeviceStorage: itemStorage.get(
|
||||
'optimizeOnDeviceStorage'
|
||||
),
|
||||
pinReminders: itemStorage.get('pinReminders'),
|
||||
screenLockTimeoutMinutes: itemStorage.get(
|
||||
'screenLockTimeoutMinutes'
|
||||
),
|
||||
autoDownloadSettings: autoDownloadPrimary
|
||||
? {
|
||||
images: autoDownloadPrimary.photos,
|
||||
audio: autoDownloadPrimary.audio,
|
||||
video: autoDownloadPrimary.videos,
|
||||
documents: autoDownloadPrimary.documents,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
defaultSentMediaQuality:
|
||||
itemStorage.get('sent-media-quality') === 'high'
|
||||
? Backups.AccountData.SentMediaQuality.HIGH
|
||||
: Backups.AccountData.SentMediaQuality.STANDARD,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1279,6 +1359,11 @@ export class BackupExportStream extends Readable {
|
||||
}
|
||||
|
||||
if (message.expireTimer) {
|
||||
if (this.options.type === 'plaintext-export') {
|
||||
// All disappearing messages are excluded in plaintext export
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (DurationInSeconds.toMillis(message.expireTimer) <= DAY) {
|
||||
// Message has an expire timer that's too short for export
|
||||
return undefined;
|
||||
@@ -2562,7 +2647,7 @@ export class BackupExportStream extends Readable {
|
||||
text:
|
||||
quote.text != null
|
||||
? {
|
||||
body: quote.text,
|
||||
body: trimBody(quote.text, BACKUP_QUOTE_BODY_LIMIT),
|
||||
bodyRanges: quote.bodyRanges?.map(range =>
|
||||
this.#toBodyRange(range)
|
||||
),
|
||||
@@ -2670,7 +2755,10 @@ export class BackupExportStream extends Readable {
|
||||
});
|
||||
|
||||
let mediaName: string | undefined;
|
||||
if (this.options.type === 'local-encrypted') {
|
||||
if (
|
||||
this.options.type === 'local-encrypted' ||
|
||||
this.options.type === 'plaintext-export'
|
||||
) {
|
||||
if (hasRequiredInformationForLocalBackup(attachment)) {
|
||||
mediaName = getLocalBackupFileNameForAttachment(attachment);
|
||||
}
|
||||
@@ -3002,7 +3090,8 @@ export class BackupExportStream extends Readable {
|
||||
const attachment = message.attachments?.at(0);
|
||||
// Integration tests use the 'link-and-sync' version of export, which will include
|
||||
// view-once attachments
|
||||
const shouldIncludeAttachments = isTestOrMockEnvironment();
|
||||
const shouldIncludeAttachments =
|
||||
this.options.type !== 'plaintext-export' && isTestOrMockEnvironment();
|
||||
return {
|
||||
attachment:
|
||||
!shouldIncludeAttachments || attachment == null
|
||||
|
||||
@@ -722,6 +722,9 @@ export class BackupImportStream extends Writable {
|
||||
donationSubscriberData,
|
||||
accountSettings,
|
||||
svrPin,
|
||||
androidSpecificSettings,
|
||||
bioText,
|
||||
bioEmoji,
|
||||
}: Backups.IAccountData): Promise<void> {
|
||||
strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData');
|
||||
const me = {
|
||||
@@ -757,6 +760,12 @@ export class BackupImportStream extends Writable {
|
||||
if (familyName != null) {
|
||||
me.profileFamilyName = familyName;
|
||||
}
|
||||
if (bioText != null) {
|
||||
me.about = bioText;
|
||||
}
|
||||
if (bioEmoji != null) {
|
||||
me.aboutEmoji = bioEmoji;
|
||||
}
|
||||
if (avatarUrlPath != null) {
|
||||
await itemStorage.put('avatarUrl', avatarUrlPath);
|
||||
}
|
||||
@@ -776,6 +785,10 @@ export class BackupImportStream extends Writable {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTestOrMockEnvironment()) {
|
||||
// Only relevant for tests
|
||||
await itemStorage.put('androidSpecificSettings', androidSpecificSettings);
|
||||
}
|
||||
|
||||
await saveBackupsSubscriberData(backupsSubscriberData);
|
||||
|
||||
@@ -855,6 +868,26 @@ export class BackupImportStream extends Writable {
|
||||
'optimizeOnDeviceStorage',
|
||||
accountSettings?.optimizeOnDeviceStorage === true
|
||||
);
|
||||
await itemStorage.put(
|
||||
'pinReminders',
|
||||
dropNull(accountSettings?.pinReminders)
|
||||
);
|
||||
await itemStorage.put(
|
||||
'screenLockTimeoutMinutes',
|
||||
dropNull(accountSettings?.screenLockTimeoutMinutes)
|
||||
);
|
||||
|
||||
const autoDownload = accountSettings?.autoDownloadSettings;
|
||||
if (autoDownload) {
|
||||
const autoDownloadEnum =
|
||||
Backups.AccountData.AutoDownloadSettings.AutoDownloadOption;
|
||||
await itemStorage.put('auto-download-attachment-primary', {
|
||||
photos: autoDownload?.images || autoDownloadEnum.NEVER,
|
||||
audio: autoDownload?.audio || autoDownloadEnum.NEVER,
|
||||
videos: autoDownload?.video || autoDownloadEnum.NEVER,
|
||||
documents: autoDownload?.documents || autoDownloadEnum.NEVER,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.#backupTier = accountSettings?.backupTier?.toNumber();
|
||||
@@ -863,6 +896,18 @@ export class BackupImportStream extends Writable {
|
||||
accountSettings?.backupTier?.toNumber()
|
||||
);
|
||||
|
||||
await itemStorage.put(
|
||||
'sealedSenderIndicators',
|
||||
accountSettings?.sealedSenderIndicators === true
|
||||
);
|
||||
await itemStorage.put(
|
||||
'sent-media-quality',
|
||||
accountSettings?.defaultSentMediaQuality ===
|
||||
Backups.AccountData.SentMediaQuality.HIGH
|
||||
? 'high'
|
||||
: 'standard'
|
||||
);
|
||||
|
||||
const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData;
|
||||
switch (accountSettings?.phoneNumberSharingMode) {
|
||||
case BackupMode.EVERYBODY:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pipeline } from 'node:stream/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, stat, unlink } from 'node:fs/promises';
|
||||
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import fsExtra from 'fs-extra';
|
||||
import { join } from 'node:path';
|
||||
import { createGzip, createGunzip } from 'node:zlib';
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
BackupImportOptions,
|
||||
ExportResultType,
|
||||
LocalBackupExportResultType,
|
||||
OnProgressCallback,
|
||||
} from './types.std.js';
|
||||
import {
|
||||
BackupInstallerError,
|
||||
@@ -94,11 +95,27 @@ import {
|
||||
readLocalBackupFilesList,
|
||||
validateLocalBackupStructure,
|
||||
} from './util/localBackup.node.js';
|
||||
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager.preload.js';
|
||||
import {
|
||||
AttachmentPermanentlyMissingError,
|
||||
getJobIdForLogging,
|
||||
runAttachmentBackupJob,
|
||||
} from '../../jobs/AttachmentLocalBackupManager.preload.js';
|
||||
import { decipherWithAesKey } from '../../util/decipherWithAesKey.node.js';
|
||||
import { areRemoteBackupsTurnedOn } from '../../util/isBackupEnabled.preload.js';
|
||||
import { unlink as unlinkAccount } from '../../textsecure/WebAPI.preload.js';
|
||||
import {
|
||||
isOnline,
|
||||
unlink as unlinkAccount,
|
||||
} from '../../textsecure/WebAPI.preload.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import { LOCAL_BACKUP_VERSION } from './constants.std.js';
|
||||
import { getTimestampForFolder } from '../../util/timestamp.std.js';
|
||||
import { MEBIBYTE } from '../../types/AttachmentSize.std.js';
|
||||
import {
|
||||
NotEnoughStorageError,
|
||||
RanOutOfStorageError,
|
||||
StoragePermissionsError,
|
||||
} from '../../types/Backups.std.js';
|
||||
import { getFreeDiskSpace } from '../../util/getFreeDiskSpace.node.js';
|
||||
|
||||
const { ensureFile } = fsExtra;
|
||||
|
||||
@@ -320,27 +337,137 @@ export class BackupsService {
|
||||
}
|
||||
|
||||
public async exportLocalBackup(
|
||||
backupsBaseDir: string | undefined = undefined
|
||||
backupsBaseDir: string,
|
||||
options: BackupExportOptions
|
||||
): Promise<LocalBackupExportResultType> {
|
||||
strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled');
|
||||
|
||||
if (isOnline()) {
|
||||
await this.#waitForEmptyQueues('backups.exportLocalBackup');
|
||||
} else {
|
||||
log.info('exportLocalBackup: Offline; skipping wait for empty queues');
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
backupsBaseDir ??
|
||||
join(window.SignalContext.getPath('userData'), 'SignalBackups');
|
||||
const snapshotDir = join(baseDir, `signal-backup-${new Date().getTime()}`);
|
||||
const snapshotDir = join(
|
||||
baseDir,
|
||||
`signal-backup-${getTimestampForFolder()}`
|
||||
);
|
||||
await mkdir(snapshotDir, { recursive: true });
|
||||
const mainProtoPath = join(snapshotDir, 'main');
|
||||
|
||||
log.info('exportLocalBackup: starting');
|
||||
const isPlaintextExport = options.type === 'plaintext-export';
|
||||
const mainProtoPath = join(
|
||||
snapshotDir,
|
||||
isPlaintextExport ? 'main.jsonl' : 'main'
|
||||
);
|
||||
|
||||
const exportResult = await this.exportToDisk(mainProtoPath, {
|
||||
type: 'local-encrypted',
|
||||
log.info(`exportLocalBackup: starting with type=${options.type}`);
|
||||
|
||||
const exportResult = await this.exportToDisk(
|
||||
mainProtoPath,
|
||||
options.type === 'local-encrypted'
|
||||
? {
|
||||
...options,
|
||||
localBackupSnapshotDir: snapshotDir,
|
||||
});
|
||||
}
|
||||
: options
|
||||
);
|
||||
|
||||
const { attachmentBackupJobs } = exportResult;
|
||||
let totalAttachmentBytes = 0;
|
||||
if (options.type === 'plaintext-export' && !options.shouldIncludeMedia) {
|
||||
log.info(
|
||||
`BackupExportStream: shouldIncludeMedia=false, not adding ${attachmentBackupJobs.length} attachment jobs`
|
||||
);
|
||||
} else if (
|
||||
options.type === 'plaintext-export' ||
|
||||
options.type === 'local-encrypted'
|
||||
) {
|
||||
const onProgress =
|
||||
options.type === 'plaintext-export' ? options.onProgress : undefined;
|
||||
const abortSignal =
|
||||
options.type === 'plaintext-export' ? options.abortSignal : undefined;
|
||||
let currentBytes = 0;
|
||||
|
||||
log.info(
|
||||
`BackupExportStream: About to process ${attachmentBackupJobs.length} jobs`
|
||||
);
|
||||
for (const job of attachmentBackupJobs) {
|
||||
if (job.type !== 'local') {
|
||||
log.error(
|
||||
"BackupExportStream: Can't process remote backup jobs during local backup, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalAttachmentBytes += job.data.size;
|
||||
}
|
||||
|
||||
const freeSpaceBytes = await getFreeDiskSpace(baseDir);
|
||||
const bufferBytes = 100 * MEBIBYTE;
|
||||
const bytesNeeded = totalAttachmentBytes + bufferBytes - freeSpaceBytes;
|
||||
if (bytesNeeded > 0) {
|
||||
log.info(
|
||||
`exportLocalBackup: Not enough storage; only ${freeSpaceBytes} available, ${totalAttachmentBytes} of attachments to export`
|
||||
);
|
||||
throw new NotEnoughStorageError(bytesNeeded);
|
||||
}
|
||||
|
||||
for (const job of attachmentBackupJobs) {
|
||||
if (job.type !== 'local') {
|
||||
log.error(
|
||||
'exportLocalBackup: Cannot process remote backup jobs during local backup, skipping'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
log.info(
|
||||
'exportLocalBackup: Aborted; exiting before processing all attachment jobs'
|
||||
);
|
||||
throw new Error('User aborted the export!');
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await runAttachmentBackupJob(job, baseDir);
|
||||
|
||||
currentBytes += job.data.size;
|
||||
onProgress?.(currentBytes, totalAttachmentBytes);
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentPermanentlyMissingError) {
|
||||
log.error(
|
||||
`${getJobIdForLogging(job)}: Attachment was not found; continuing with export`
|
||||
);
|
||||
currentBytes += job.data.size;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stillToExportBytes = totalAttachmentBytes - currentBytes;
|
||||
if (error.code === 'ENOSPC') {
|
||||
throw new RanOutOfStorageError(stillToExportBytes);
|
||||
}
|
||||
if (error.code === 'EPERM' || error.code === 'EACCES') {
|
||||
throw new StoragePermissionsError();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('exportLocalBackup: writing metadata');
|
||||
if (isPlaintextExport) {
|
||||
const metadataPath = join(snapshotDir, 'metadata.json');
|
||||
await writeFile(
|
||||
metadataPath,
|
||||
JSON.stringify({
|
||||
version: LOCAL_BACKUP_VERSION,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const metadataArgs = {
|
||||
snapshotDir,
|
||||
backupId: getBackupId(),
|
||||
@@ -348,14 +475,10 @@ export class BackupsService {
|
||||
};
|
||||
await writeLocalBackupMetadata(metadataArgs);
|
||||
await verifyLocalBackupMetadata(metadataArgs);
|
||||
|
||||
log.info(
|
||||
'exportLocalBackup: waiting for AttachmentLocalBackupManager to finish'
|
||||
);
|
||||
await AttachmentLocalBackupManager.waitForIdle();
|
||||
}
|
||||
|
||||
log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`);
|
||||
return { ...exportResult, snapshotDir };
|
||||
return { ...exportResult, snapshotDir, totalAttachmentBytes };
|
||||
}
|
||||
|
||||
public async stageLocalBackupForImport(
|
||||
@@ -431,7 +554,7 @@ export class BackupsService {
|
||||
options
|
||||
);
|
||||
|
||||
if (options.type !== 'cross-client-integration-test') {
|
||||
if (options.type === 'local-encrypted' || options.type === 'remote') {
|
||||
await validateBackup(
|
||||
() => new FileStream(path),
|
||||
exportResult.totalBytes,
|
||||
@@ -453,13 +576,68 @@ export class BackupsService {
|
||||
return { error: 'Backups directory not selected' };
|
||||
}
|
||||
|
||||
const result = await this.exportLocalBackup(backupsBaseDir);
|
||||
const result = await this.exportLocalBackup(backupsBaseDir, {
|
||||
type: 'local-encrypted',
|
||||
localBackupSnapshotDir: backupsBaseDir,
|
||||
});
|
||||
return { result };
|
||||
} catch (error) {
|
||||
return { error: Errors.toLogFormat(error) };
|
||||
}
|
||||
}
|
||||
|
||||
public async exportPlaintext({
|
||||
abortSignal,
|
||||
onProgress,
|
||||
shouldIncludeMedia,
|
||||
targetPath,
|
||||
}: {
|
||||
abortSignal: AbortSignal;
|
||||
onProgress: OnProgressCallback;
|
||||
shouldIncludeMedia: boolean;
|
||||
targetPath: string;
|
||||
}): Promise<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> {
|
||||
const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
|
||||
'show-open-folder-dialog'
|
||||
@@ -492,7 +670,12 @@ export class BackupsService {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
return {
|
||||
result: { duration, stats: recordStream.getStats(), totalBytes },
|
||||
result: {
|
||||
attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
|
||||
duration,
|
||||
stats: recordStream.getStats(),
|
||||
totalBytes,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: Errors.toLogFormat(error) };
|
||||
@@ -902,12 +1085,20 @@ export class BackupsService {
|
||||
if (window.SignalCI || options.type === 'cross-client-integration-test') {
|
||||
strictAssert(
|
||||
isTestOrMockEnvironment(),
|
||||
'Plaintext backups can be exported only in test harness'
|
||||
'exportBackup: Plaintext backups can be exported only in test harness'
|
||||
);
|
||||
} else if (
|
||||
!isOnline() &&
|
||||
(options.type === 'local-encrypted' ||
|
||||
options.type === 'plaintext-export')
|
||||
) {
|
||||
log.info(
|
||||
`exportBackup: Skipping CDN update; offline at type is ${options.type}`
|
||||
);
|
||||
} else {
|
||||
// We first fetch the latest info on what's on the CDN, since this affects the
|
||||
// filePointers we will generate during export
|
||||
log.info('Fetching latest backup CDN metadata');
|
||||
log.info('exportBackup: Fetching latest backup CDN metadata');
|
||||
await this.fetchAndSaveBackupCdnObjectMetadata();
|
||||
}
|
||||
|
||||
@@ -942,9 +1133,28 @@ export class BackupsService {
|
||||
case 'cross-client-integration-test':
|
||||
strictAssert(
|
||||
isTestOrMockEnvironment(),
|
||||
'Plaintext backups can be exported only in test harness'
|
||||
'exportBackup: Plaintext backups can be exported only in test harness'
|
||||
);
|
||||
await pipeline(
|
||||
recordStream,
|
||||
measureSize({
|
||||
onComplete: size => {
|
||||
totalBytes = size;
|
||||
},
|
||||
}),
|
||||
sink
|
||||
);
|
||||
break;
|
||||
case 'plaintext-export':
|
||||
await pipeline(
|
||||
recordStream,
|
||||
measureSize({
|
||||
onComplete: size => {
|
||||
totalBytes = size;
|
||||
},
|
||||
}),
|
||||
sink
|
||||
);
|
||||
await pipeline(recordStream, sink);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
@@ -966,7 +1176,12 @@ export class BackupsService {
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
return { totalBytes, stats: recordStream.getStats(), duration };
|
||||
return {
|
||||
attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
|
||||
totalBytes,
|
||||
stats: recordStream.getStats(),
|
||||
duration,
|
||||
};
|
||||
} finally {
|
||||
log.info('exportBackup: finished...');
|
||||
this.#isRunning = false;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
import type { AciString, PniString } from '../../types/ServiceId.std.js';
|
||||
import type { ConversationColorType } from '../../types/Colors.std.js';
|
||||
import type {
|
||||
CoreAttachmentBackupJobType,
|
||||
CoreAttachmentLocalBackupJobType,
|
||||
} from '../../types/AttachmentBackup.std.js';
|
||||
|
||||
// Duplicated here to allow loading it in a non-node environment
|
||||
export enum BackupLevel {
|
||||
@@ -25,8 +29,22 @@ export type AboutMe = {
|
||||
e164?: string;
|
||||
};
|
||||
|
||||
export type OnProgressCallback = (
|
||||
currentBytes: number,
|
||||
totalBytes: number
|
||||
) => void;
|
||||
|
||||
export type BackupExportOptions =
|
||||
| { type: 'remote' | 'cross-client-integration-test'; level: BackupLevel }
|
||||
| {
|
||||
type: 'remote' | 'cross-client-integration-test';
|
||||
level: BackupLevel;
|
||||
}
|
||||
| {
|
||||
type: 'plaintext-export';
|
||||
abortSignal: AbortSignal;
|
||||
onProgress: OnProgressCallback;
|
||||
shouldIncludeMedia: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'local-encrypted';
|
||||
localBackupSnapshotDir: string;
|
||||
@@ -39,7 +57,7 @@ export type BackupImportOptions = (
|
||||
}
|
||||
) & {
|
||||
ephemeralKey?: Uint8Array;
|
||||
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
||||
onProgress?: OnProgressCallback;
|
||||
};
|
||||
|
||||
export type LocalChatStyle = Readonly<{
|
||||
@@ -66,6 +84,9 @@ export type StatsType = {
|
||||
};
|
||||
|
||||
export type ExportResultType = Readonly<{
|
||||
attachmentBackupJobs: ReadonlyArray<
|
||||
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
|
||||
>;
|
||||
totalBytes: number;
|
||||
duration: number;
|
||||
stats: Readonly<StatsType>;
|
||||
@@ -73,4 +94,5 @@ export type ExportResultType = Readonly<{
|
||||
|
||||
export type LocalBackupExportResultType = ExportResultType & {
|
||||
snapshotDir: string;
|
||||
totalAttachmentBytes: number;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { strictAssert } from '../../../util/assert.std.js';
|
||||
import type {
|
||||
CoreAttachmentBackupJobType,
|
||||
PartialAttachmentLocalBackupJobType,
|
||||
CoreAttachmentLocalBackupJobType,
|
||||
} from '../../../types/AttachmentBackup.std.js';
|
||||
import {
|
||||
type GetBackupCdnInfoType,
|
||||
@@ -208,7 +208,7 @@ export async function getFilePointerForAttachment({
|
||||
messageReceivedAt: number;
|
||||
}): Promise<{
|
||||
filePointer: Backups.FilePointer;
|
||||
backupJob?: CoreAttachmentBackupJobType | PartialAttachmentLocalBackupJobType;
|
||||
backupJob?: CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType;
|
||||
}> {
|
||||
const attachment = maybeFixupAttachment(rawAttachment);
|
||||
|
||||
@@ -246,7 +246,9 @@ export async function getFilePointerForAttachment({
|
||||
? await getBackupCdnInfo(remoteMediaId.string)
|
||||
: { isInBackupTier: false };
|
||||
|
||||
const isLocalBackup = backupOptions.type === 'local-encrypted';
|
||||
const isLocalBackup =
|
||||
backupOptions.type === 'local-encrypted' ||
|
||||
backupOptions.type === 'plaintext-export';
|
||||
filePointer.locatorInfo = getLocatorInfoForAttachment({
|
||||
attachment,
|
||||
backupOptions,
|
||||
@@ -262,10 +264,15 @@ export async function getFilePointerForAttachment({
|
||||
return {
|
||||
filePointer,
|
||||
backupJob: {
|
||||
isPlaintextExport: backupOptions.type === 'plaintext-export',
|
||||
mediaName: getLocalBackupFileNameForAttachment(attachment),
|
||||
type: 'local',
|
||||
data: {
|
||||
contentType: attachment.contentType,
|
||||
fileName: attachment.fileName,
|
||||
localKey: attachment.localKey,
|
||||
path: attachment.path,
|
||||
size: attachment.size,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -362,7 +369,9 @@ function getLocatorInfoForAttachment({
|
||||
}): Backups.FilePointer.LocatorInfo {
|
||||
const locatorInfo = new Backups.FilePointer.LocatorInfo();
|
||||
|
||||
const isLocalBackup = backupOptions.type === 'local-encrypted';
|
||||
const isLocalBackup =
|
||||
backupOptions.type === 'local-encrypted' ||
|
||||
backupOptions.type === 'plaintext-export';
|
||||
|
||||
const shouldBeLocallyBackedUp =
|
||||
isLocalBackup &&
|
||||
|
||||
@@ -46,7 +46,10 @@ export async function validateBackup(
|
||||
`Backup validation failed: ${outcome.errorMessage}`
|
||||
);
|
||||
} else if (type === ValidationType.Export) {
|
||||
strictAssert(outcome.ok, 'Backup validation failed');
|
||||
strictAssert(
|
||||
outcome.ok,
|
||||
`Backup validation failed: ${outcome.errorMessage}`
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createExpiringEntityCleanupService(
|
||||
|
||||
function cancelNextScheduledRun(reason: string) {
|
||||
if (controller != null) {
|
||||
log.warn(`cancel(${reason}) cancelling next scheduled run`);
|
||||
log.warn(`cancel(${reason}) canceling next scheduled run`);
|
||||
controller.abort(reason);
|
||||
controller = null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts.preload.js';
|
||||
import { actions as app } from './ducks/app.preload.js';
|
||||
import { actions as audioPlayer } from './ducks/audioPlayer.preload.js';
|
||||
import { actions as audioRecorder } from './ducks/audioRecorder.preload.js';
|
||||
import { actions as backups } from './ducks/backups.preload.js';
|
||||
import { actions as badges } from './ducks/badges.preload.js';
|
||||
import { actions as callHistory } from './ducks/callHistory.preload.js';
|
||||
import { actions as calling } from './ducks/calling.preload.js';
|
||||
@@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = {
|
||||
app,
|
||||
audioPlayer,
|
||||
audioRecorder,
|
||||
backups,
|
||||
badges,
|
||||
callHistory,
|
||||
calling,
|
||||
|
||||
413
ts/state/ducks/backups.preload.ts
Normal file
413
ts/state/ducks/backups.preload.ts
Normal 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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getEmptyState as accountsEmptyState } from './ducks/accounts.preload.js
|
||||
import { getEmptyState as appEmptyState } from './ducks/app.preload.js';
|
||||
import { getEmptyState as audioPlayerEmptyState } from './ducks/audioPlayer.preload.js';
|
||||
import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder.preload.js';
|
||||
import { getEmptyState as backupsEmptyState } from './ducks/backups.preload.js';
|
||||
import { getEmptyState as badgesEmptyState } from './ducks/badges.preload.js';
|
||||
import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.preload.js';
|
||||
import { getEmptyState as callingEmptyState } from './ducks/calling.preload.js';
|
||||
@@ -149,6 +150,7 @@ function getEmptyState(): StateType {
|
||||
app: appEmptyState(),
|
||||
audioPlayer: audioPlayerEmptyState(),
|
||||
audioRecorder: audioRecorderEmptyState(),
|
||||
backups: backupsEmptyState(),
|
||||
badges: badgesEmptyState(),
|
||||
callHistory: callHistoryEmptyState(),
|
||||
calling: callingEmptyState(),
|
||||
|
||||
@@ -56,6 +56,7 @@ export function initializeRedux(data: ReduxInitData): void {
|
||||
actionCreators.audioRecorder,
|
||||
store.dispatch
|
||||
),
|
||||
backups: bindActionCreators(actionCreators.backups, store.dispatch),
|
||||
badges: bindActionCreators(actionCreators.badges, store.dispatch),
|
||||
callHistory: bindActionCreators(actionCreators.callHistory, store.dispatch),
|
||||
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts.preload.js';
|
||||
import { reducer as app } from './ducks/app.preload.js';
|
||||
import { reducer as audioPlayer } from './ducks/audioPlayer.preload.js';
|
||||
import { reducer as audioRecorder } from './ducks/audioRecorder.preload.js';
|
||||
import { reducer as backups } from './ducks/backups.preload.js';
|
||||
import { reducer as badges } from './ducks/badges.preload.js';
|
||||
import { reducer as calling } from './ducks/calling.preload.js';
|
||||
import { reducer as callHistory } from './ducks/callHistory.preload.js';
|
||||
@@ -44,6 +45,7 @@ export const reducer = combineReducers({
|
||||
app,
|
||||
audioPlayer,
|
||||
audioRecorder,
|
||||
backups,
|
||||
badges,
|
||||
calling,
|
||||
callHistory,
|
||||
|
||||
41
ts/state/selectors/backups.std.ts
Normal file
41
ts/state/selectors/backups.std.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
@@ -32,6 +32,8 @@ import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipa
|
||||
import { SmartProfileNameWarningModal } from './ProfileNameWarningModal.preload.js';
|
||||
import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal.preload.js';
|
||||
import { DebugLogErrorModal } from '../../components/DebugLogErrorModal.dom.js';
|
||||
import { SmartPlaintextExportWorkflow } from './PlaintextExportWorkflow.preload.js';
|
||||
import { shouldShowPlaintextWorkflow } from '../selectors/backups.std.js';
|
||||
|
||||
function renderCallLinkAddNameModal(): JSX.Element {
|
||||
return <SmartCallLinkAddNameModal />;
|
||||
@@ -89,6 +91,10 @@ function renderNotePreviewModal(): JSX.Element {
|
||||
return <SmartNotePreviewModal />;
|
||||
}
|
||||
|
||||
function renderPlaintextExportWorkflow(): JSX.Element {
|
||||
return <SmartPlaintextExportWorkflow />;
|
||||
}
|
||||
|
||||
function renderStoriesSettings(): JSX.Element {
|
||||
return <SmartStoriesSettingsModal />;
|
||||
}
|
||||
@@ -110,6 +116,9 @@ export const SmartGlobalModalContainer = memo(
|
||||
const conversationsStoppingSend = useSelector(getConversationsStoppingSend);
|
||||
const i18n = useSelector(getIntl);
|
||||
const theme = useSelector(getTheme);
|
||||
const shouldShowPlaintextExportWorkflow = useSelector(
|
||||
shouldShowPlaintextWorkflow
|
||||
);
|
||||
|
||||
const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0;
|
||||
|
||||
@@ -282,6 +291,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
renderMessageRequestActionsConfirmation
|
||||
}
|
||||
renderNotePreviewModal={renderNotePreviewModal}
|
||||
renderPlaintextExportWorkflow={renderPlaintextExportWorkflow}
|
||||
renderProfileNameWarningModal={renderProfileNameWarningModal}
|
||||
renderUsernameOnboarding={renderUsernameOnboarding}
|
||||
renderSafetyNumber={renderSafetyNumber}
|
||||
@@ -291,6 +301,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
renderStoriesSettings={renderStoriesSettings}
|
||||
safetyNumberChangedBlockingData={safetyNumberChangedBlockingData}
|
||||
safetyNumberModalContactId={safetyNumberModalContactId}
|
||||
shouldShowPlaintextExportWorkflow={shouldShowPlaintextExportWorkflow}
|
||||
stickerPackPreviewId={stickerPackPreviewId}
|
||||
tapToViewNotAvailableModalProps={tapToViewNotAvailableModalProps}
|
||||
theme={theme}
|
||||
|
||||
61
ts/state/smart/PlaintextExportWorkflow.preload.tsx
Normal file
61
ts/state/smart/PlaintextExportWorkflow.preload.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
|
||||
import { SmartProfileEditor } from './ProfileEditor.preload.js';
|
||||
import { useNavActions } from '../ducks/nav.std.js';
|
||||
import type { SettingsLocation } from '../../types/Nav.std.js';
|
||||
import { NavTab } from '../../types/Nav.std.js';
|
||||
import { SmartToastManager } from './ToastManager.preload.js';
|
||||
import { useToastActions } from '../ducks/toast.preload.js';
|
||||
@@ -83,7 +82,24 @@ import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.dom.js';
|
||||
import { SmartPreferencesDonations } from './PreferencesDonations.preload.js';
|
||||
import { useDonationsActions } from '../ducks/donations.preload.js';
|
||||
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt.dom.js';
|
||||
import { getProfiles } from '../selectors/notificationProfiles.dom.js';
|
||||
import { backupLevelFromNumber } from '../../services/backups/types.std.js';
|
||||
import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.js';
|
||||
import { useBackupActions } from '../ducks/backups.preload.js';
|
||||
import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js';
|
||||
import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.js';
|
||||
import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.js';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.js';
|
||||
import {
|
||||
getCurrentChatFoldersCount,
|
||||
getHasAnyCurrentCustomChatFolders,
|
||||
} from '../selectors/chatFolders.std.js';
|
||||
import {
|
||||
SmartNotificationProfilesCreateFlow,
|
||||
SmartNotificationProfilesHome,
|
||||
} from './PreferencesNotificationProfiles.preload.js';
|
||||
|
||||
import type { SettingsLocation } from '../../types/Nav.std.js';
|
||||
import type {
|
||||
StorageAccessType,
|
||||
ZoomFactorType,
|
||||
@@ -100,22 +116,8 @@ import {
|
||||
} from '../../util/backupMediaDownload.preload.js';
|
||||
import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary.dom.js';
|
||||
import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.js';
|
||||
import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.js';
|
||||
import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.preload.js';
|
||||
import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.js';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.dom.js';
|
||||
import {
|
||||
getCurrentChatFoldersCount,
|
||||
getHasAnyCurrentCustomChatFolders,
|
||||
} from '../selectors/chatFolders.std.js';
|
||||
import {
|
||||
SmartNotificationProfilesCreateFlow,
|
||||
SmartNotificationProfilesHome,
|
||||
} from './PreferencesNotificationProfiles.preload.js';
|
||||
import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.preload.js';
|
||||
import { getProfiles } from '../selectors/notificationProfiles.dom.js';
|
||||
import { backupLevelFromNumber } from '../../services/backups/types.std.js';
|
||||
import { getMessageQueueTime } from '../../util/getMessageQueueTime.dom.js';
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTING = 'message';
|
||||
|
||||
@@ -226,6 +228,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
const { changeLocation } = useNavActions();
|
||||
const { showToast } = useToastActions();
|
||||
const { internalAddDonationReceipt } = useDonationsActions();
|
||||
const { startPlaintextExport } = useBackupActions();
|
||||
|
||||
// Selectors
|
||||
|
||||
@@ -585,6 +588,13 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
const backupLocalBackupsEnabled = isLocalBackupsEnabled(items.remoteConfig);
|
||||
const backupFreeMediaDays = getMessageQueueTime(items.remoteConfig) / DAY;
|
||||
|
||||
const isPlaintextExportEnabled = isFeaturedEnabledSelector({
|
||||
betaKey: 'desktop.plaintextExport.beta',
|
||||
currentVersion: version,
|
||||
remoteConfig: items.remoteConfig,
|
||||
prodKey: 'desktop.plaintextExport.prod',
|
||||
});
|
||||
|
||||
// Two-way items
|
||||
|
||||
function createItemsAccess<K extends keyof StorageAccessType>(
|
||||
@@ -848,6 +858,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
isMinimizeToAndStartInSystemTraySupported
|
||||
}
|
||||
isNotificationAttentionSupported={isNotificationAttentionSupported}
|
||||
isPlaintextExportEnabled={isPlaintextExportEnabled}
|
||||
isSyncSupported={isSyncSupported}
|
||||
isSystemTraySupported={isSystemTraySupported}
|
||||
isInternalUser={isInternalUser}
|
||||
@@ -937,6 +948,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
setSettingsLocation={setSettingsLocation}
|
||||
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
||||
showToast={showToast}
|
||||
startPlaintextExport={startPlaintextExport}
|
||||
theme={theme}
|
||||
themeSetting={themeSetting}
|
||||
universalExpireTimer={universalExpireTimer}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { actions as accounts } from './ducks/accounts.preload.js';
|
||||
import type { actions as app } from './ducks/app.preload.js';
|
||||
import type { actions as audioPlayer } from './ducks/audioPlayer.preload.js';
|
||||
import type { actions as audioRecorder } from './ducks/audioRecorder.preload.js';
|
||||
import type { actions as backups } from './ducks/backups.preload.js';
|
||||
import type { actions as badges } from './ducks/badges.preload.js';
|
||||
import type { actions as callHistory } from './ducks/callHistory.preload.js';
|
||||
import type { actions as calling } from './ducks/calling.preload.js';
|
||||
@@ -45,6 +46,7 @@ export type ReduxActions = {
|
||||
app: typeof app;
|
||||
audioPlayer: typeof audioPlayer;
|
||||
audioRecorder: typeof audioRecorder;
|
||||
backups: typeof backups;
|
||||
badges: typeof badges;
|
||||
callHistory: typeof callHistory;
|
||||
calling: typeof calling;
|
||||
|
||||
@@ -477,8 +477,13 @@ describe('getFilePointerForAttachment', () => {
|
||||
}),
|
||||
}),
|
||||
backupJob: {
|
||||
isPlaintextExport: false,
|
||||
data: {
|
||||
contentType: defaultAttachment.contentType,
|
||||
fileName: defaultAttachment.fileName,
|
||||
localKey: defaultAttachment.localKey,
|
||||
path: defaultAttachment.path,
|
||||
size: defaultAttachment.size,
|
||||
},
|
||||
mediaName: defaultLocalMediaName,
|
||||
type: 'local',
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
|
||||
|
||||
// Confirm they are saved to DB
|
||||
const allJobs = await getAllSavedJobs();
|
||||
assert.strictEqual(allJobs.length, 10);
|
||||
assert.strictEqual(allJobs.length, 10, 'initial setup');
|
||||
|
||||
await backupManager?.start();
|
||||
await waitForJobToBeStarted(jobs[2]);
|
||||
@@ -246,11 +246,11 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
|
||||
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]);
|
||||
|
||||
await waitForJobToBeStarted(jobs[0]);
|
||||
assert.strictEqual(runJob.callCount, 5);
|
||||
assert.strictEqual(runJob.callCount, 5, 'first calls');
|
||||
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]);
|
||||
|
||||
await waitForJobToBeCompleted(thumbnailJobs[0]);
|
||||
assert.strictEqual(runJob.callCount, 10);
|
||||
assert.strictEqual(runJob.callCount, 10, 'all calls');
|
||||
|
||||
assertRunJobCalledWith([
|
||||
jobs[4],
|
||||
|
||||
187
ts/test-electron/util/isFeatureEnabled_test.dom.ts
Normal file
187
ts/test-electron/util/isFeatureEnabled_test.dom.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,21 +45,17 @@ export type ThumbnailAttachmentBackupJobType = {
|
||||
|
||||
export type CoreAttachmentLocalBackupJobType = {
|
||||
type: 'local';
|
||||
isPlaintextExport: boolean;
|
||||
mediaName: string;
|
||||
data: {
|
||||
contentType: MIMEType;
|
||||
fileName: string | undefined;
|
||||
localKey: string;
|
||||
path: string | null;
|
||||
size: number;
|
||||
};
|
||||
backupsBaseDir: string;
|
||||
};
|
||||
|
||||
export type PartialAttachmentLocalBackupJobType = Omit<
|
||||
CoreAttachmentLocalBackupJobType,
|
||||
'backupsBaseDir'
|
||||
>;
|
||||
|
||||
export type AttachmentLocalBackupJobType = CoreAttachmentLocalBackupJobType &
|
||||
JobManagerJobType;
|
||||
|
||||
const standardBackupJobDataSchema = z.object({
|
||||
type: z.literal('standard'),
|
||||
mediaName: z.string(),
|
||||
|
||||
126
ts/types/Backups.std.ts
Normal file
126
ts/types/Backups.std.ts
Normal 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([]),
|
||||
};
|
||||
@@ -45,6 +45,7 @@ export const rendererConfigSchema = z.object({
|
||||
disableIPv6: z.boolean(),
|
||||
disableScreenSecurity: z.boolean(),
|
||||
dnsFallback: DNSFallbackSchema,
|
||||
downloadsPath: configRequiredStringSchema,
|
||||
environment: environmentSchema,
|
||||
isMockTestEnvironment: z.boolean(),
|
||||
homePath: configRequiredStringSchema,
|
||||
|
||||
11
ts/types/Storage.d.ts
vendored
11
ts/types/Storage.d.ts
vendored
@@ -246,6 +246,17 @@ export type StorageAccessType = {
|
||||
// Stored solely for pesistance during import/export sequence
|
||||
svrPin: string;
|
||||
optimizeOnDeviceStorage: boolean;
|
||||
pinReminders: boolean | undefined;
|
||||
screenLockTimeoutMinutes: number | undefined;
|
||||
'auto-download-attachment-primary':
|
||||
| undefined
|
||||
| {
|
||||
photos: number;
|
||||
audio: number;
|
||||
videos: number;
|
||||
documents: number;
|
||||
};
|
||||
androidSpecificSettings: unknown;
|
||||
|
||||
postRegistrationSyncsStatus: 'incomplete' | 'complete';
|
||||
|
||||
|
||||
9
ts/util/getFreeDiskSpace.node.ts
Normal file
9
ts/util/getFreeDiskSpace.node.ts
Normal 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;
|
||||
}
|
||||
98
ts/util/isFeatureEnabled.dom.ts
Normal file
98
ts/util/isFeatureEnabled.dom.ts
Normal 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;
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import { missingCaseError } from '../missingCaseError.std.js';
|
||||
|
||||
const log = createLogger('promptOSAuthMain');
|
||||
|
||||
export type PromptOSAuthReasonType = 'enable-backups' | 'view-aep';
|
||||
export type PromptOSAuthReasonType =
|
||||
| 'enable-backups'
|
||||
| 'view-aep'
|
||||
| 'plaintext-export';
|
||||
|
||||
export type PromptOSAuthResultType =
|
||||
| 'error'
|
||||
@@ -101,6 +104,9 @@ async function promptOSAuthLinux(
|
||||
'pkcheck -u --process $$ --action-id org.signalapp.enable-backups';
|
||||
} else if (reason === 'view-aep') {
|
||||
command = 'pkcheck -u --process $$ --action-id org.signalapp.view-aep';
|
||||
} else if (reason === 'plaintext-export') {
|
||||
command =
|
||||
'pkcheck -u --process $$ --action-id org.signalapp.plaintext-export';
|
||||
} else {
|
||||
throw missingCaseError(reason);
|
||||
}
|
||||
@@ -112,6 +118,7 @@ async function promptOSAuthLinux(
|
||||
} else if (code === 3) {
|
||||
resolve('unauthorized');
|
||||
} else {
|
||||
log.warn(`promptOSAuthLinux: Got code ${code} from call to pkcheck`);
|
||||
resolve('error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PromptOSAuthReasonType,
|
||||
PromptOSAuthResultType,
|
||||
} from './os/promptOSAuthMain.main.js';
|
||||
import { missingCaseError } from './missingCaseError.std.js';
|
||||
|
||||
export async function promptOSAuth(
|
||||
reason: PromptOSAuthReasonType
|
||||
@@ -16,23 +17,46 @@ export async function promptOSAuth(
|
||||
// TODO: DESKTOP-8895
|
||||
if (window.Signal.OS.isMacOS()) {
|
||||
if (reason === 'enable-backups') {
|
||||
localeString = 'enable backups';
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:Preferences__local-backups--enable--os-prompt--mac'
|
||||
);
|
||||
} else if (reason === 'plaintext-export') {
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:PlaintextExport--OSPrompt--Mac'
|
||||
);
|
||||
} else if (reason === 'view-aep') {
|
||||
localeString = 'show your backup key';
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:Preferences--local-backups--view-backup-key--os-prompt--mac'
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.Signal.OS.isWindows()) {
|
||||
if (reason === 'enable-backups') {
|
||||
localeString = 'Verify your identity to enable backups.';
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:Preferences__local-backups--enable--os-prompt--windows'
|
||||
);
|
||||
} else if (reason === 'plaintext-export') {
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:PlaintextExport--OSPrompt--Windows'
|
||||
);
|
||||
} else if (reason === 'view-aep') {
|
||||
localeString = 'Verify your identity to view your backup key.';
|
||||
localeString = window.SignalContext.i18n(
|
||||
'icu:Preferences--local-backups--view-backup-key--os-prompt--windows'
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(reason);
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.once(`prompt-os-auth:${reason}`, (_, response) => {
|
||||
resolve(response ?? 'error');
|
||||
});
|
||||
ipcRenderer.send('prompt-os-auth', { reason, localeString });
|
||||
ipcRenderer.send('prompt-os-auth', {
|
||||
reason,
|
||||
localeString,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,3 +42,15 @@ export const MIN_SAFE_DATE = -8640000000000000;
|
||||
export function toBoundedDate(timestamp: number): Date {
|
||||
return new Date(Math.max(MIN_SAFE_DATE, Math.min(timestamp, MAX_SAFE_DATE)));
|
||||
}
|
||||
|
||||
export function addLeadingZero(amount: number): string {
|
||||
if (amount < 10) {
|
||||
return `0${amount}`;
|
||||
}
|
||||
return amount.toString();
|
||||
}
|
||||
|
||||
export function getTimestampForFolder(): string {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${addLeadingZero(date.getMonth() + 1)}-${addLeadingZero(date.getDate())}-${addLeadingZero(date.getHours())}-${addLeadingZero(date.getMinutes())}-${addLeadingZero(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user