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