Basic support for local encrypted backups
This commit is contained in:
parent
2df601b135
commit
a2c74c3a8b
37 changed files with 1782 additions and 176 deletions
|
@ -6652,6 +6652,26 @@
|
||||||
"messageformat": "Internal",
|
"messageformat": "Internal",
|
||||||
"description": "Button to switch the settings view to control internal configuration"
|
"description": "Button to switch the settings view to control internal configuration"
|
||||||
},
|
},
|
||||||
|
"icu:Preferences__internal__local-backups": {
|
||||||
|
"messageformat": "Local backups",
|
||||||
|
"description": "Text header for internal local backup tools"
|
||||||
|
},
|
||||||
|
"icu:Preferences__internal__export-local-backup": {
|
||||||
|
"messageformat": "Export…",
|
||||||
|
"description": "Button to run internal local backup export tool"
|
||||||
|
},
|
||||||
|
"icu:Preferences__internal__export-local-backup--description": {
|
||||||
|
"messageformat": "Export local encrypted backup to a folder and validate it",
|
||||||
|
"description": "Description of the internal local backup export tool"
|
||||||
|
},
|
||||||
|
"icu:Preferences__internal__import-local-backup": {
|
||||||
|
"messageformat": "Import…",
|
||||||
|
"description": "Button to run internal local backup import tool"
|
||||||
|
},
|
||||||
|
"icu:Preferences__internal__import-local-backup--description": {
|
||||||
|
"messageformat": "Stage a local encrypted backup for import on link",
|
||||||
|
"description": "Description of the internal local backup export tool"
|
||||||
|
},
|
||||||
"icu:Preferences__internal__validate-backup--description": {
|
"icu:Preferences__internal__validate-backup--description": {
|
||||||
"messageformat": "Export encrypted backup to memory and run validation suite on it",
|
"messageformat": "Export encrypted backup to memory and run validation suite on it",
|
||||||
"description": "Description of the internal backup validation tool"
|
"description": "Description of the internal backup validation tool"
|
||||||
|
|
63
app/main.ts
63
app/main.ts
|
@ -3110,31 +3110,50 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
|
||||||
return { canceled: false, filePath: finalFilePath };
|
return { canceled: false, filePath: finalFilePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle('show-save-multi-dialog', async _event => {
|
ipc.handle(
|
||||||
if (!mainWindow) {
|
'show-open-folder-dialog',
|
||||||
getLogger().warn('show-save-multi-dialog: no main window');
|
async (
|
||||||
|
_event,
|
||||||
|
{ useMainWindow }: { useMainWindow: boolean } = { useMainWindow: false }
|
||||||
|
) => {
|
||||||
|
let canceled: boolean;
|
||||||
|
let selectedDirPaths: ReadonlyArray<string>;
|
||||||
|
|
||||||
return { canceled: true };
|
if (useMainWindow) {
|
||||||
}
|
if (!mainWindow) {
|
||||||
const { canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog(
|
getLogger().warn('show-open-folder-dialog: no main window');
|
||||||
mainWindow,
|
return { canceled: true };
|
||||||
{
|
}
|
||||||
defaultPath: app.getPath('downloads'),
|
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
({ canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog(
|
||||||
|
mainWindow,
|
||||||
|
{
|
||||||
|
defaultPath: app.getPath('downloads'),
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
}
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
({ canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog({
|
||||||
|
defaultPath: app.getPath('downloads'),
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
);
|
|
||||||
if (canceled || selectedDirPaths.length === 0) {
|
if (canceled || selectedDirPaths.length === 0) {
|
||||||
return { canceled: true };
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDirPaths.length > 1) {
|
||||||
|
getLogger().warn(
|
||||||
|
'show-open-folder-dialog: multiple directories selected'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canceled: false, dirPath: selectedDirPaths[0] };
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (selectedDirPaths.length > 1) {
|
|
||||||
getLogger().warn('show-save-multi-dialog: multiple directories selected');
|
|
||||||
|
|
||||||
return { canceled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { canceled: false, dirPath: selectedDirPaths[0] };
|
|
||||||
});
|
|
||||||
|
|
||||||
ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => {
|
ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => {
|
||||||
const role = untypedRole as MenuItemConstructorOptions['role'];
|
const role = untypedRole as MenuItemConstructorOptions['role'];
|
||||||
|
|
|
@ -725,11 +725,30 @@ message FilePointer {
|
||||||
message InvalidAttachmentLocator {
|
message InvalidAttachmentLocator {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// References attachments in a local encrypted backup.
|
||||||
|
// Importers should first attempt to read the file from the local backup,
|
||||||
|
// and on failure fallback to backup and transit cdn if possible.
|
||||||
|
message LocalLocator {
|
||||||
|
string mediaName = 1;
|
||||||
|
// Separate key used to encrypt this file for the local backup.
|
||||||
|
// Generally required. Missing field indicates attachment was not
|
||||||
|
// available locally when the backup was generated, but remote
|
||||||
|
// backup or transit info was available.
|
||||||
|
optional bytes localKey = 2;
|
||||||
|
bytes remoteKey = 3;
|
||||||
|
bytes remoteDigest = 4;
|
||||||
|
uint32 size = 5;
|
||||||
|
optional uint32 backupCdnNumber = 6;
|
||||||
|
optional string transitCdnKey = 7;
|
||||||
|
optional uint32 transitCdnNumber = 8;
|
||||||
|
}
|
||||||
|
|
||||||
// If unset, importers should consider it to be an InvalidAttachmentLocator without throwing an error.
|
// If unset, importers should consider it to be an InvalidAttachmentLocator without throwing an error.
|
||||||
oneof locator {
|
oneof locator {
|
||||||
BackupLocator backupLocator = 1;
|
BackupLocator backupLocator = 1;
|
||||||
AttachmentLocator attachmentLocator = 2;
|
AttachmentLocator attachmentLocator = 2;
|
||||||
InvalidAttachmentLocator invalidAttachmentLocator = 3;
|
InvalidAttachmentLocator invalidAttachmentLocator = 3;
|
||||||
|
LocalLocator localLocator = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional string contentType = 4;
|
optional string contentType = 4;
|
||||||
|
|
24
protos/LocalBackup.proto
Normal file
24
protos/LocalBackup.proto
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package signal.backup.local;
|
||||||
|
|
||||||
|
option java_package = "org.thoughtcrime.securesms.backup.v2.local.proto";
|
||||||
|
option swift_prefix = "LocalBackupProto_";
|
||||||
|
|
||||||
|
message Metadata {
|
||||||
|
message EncryptedBackupId {
|
||||||
|
bytes iv = 1; // 12 bytes, randomly generated
|
||||||
|
bytes encryptedId = 2; // AES-256-CTR, key = local backup metadata key, message = backup ID bytes
|
||||||
|
// local backup metadata key = hkdf(input: K_B, info: UTF8("20241011_SIGNAL_LOCAL_BACKUP_METADATA_KEY"), length: 32)
|
||||||
|
// No hash of the ID; if it's decrypted incorrectly, the main backup will fail to decrypt anyway.
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 version = 1;
|
||||||
|
EncryptedBackupId backupId = 2; // used to decrypt the backup file knowing only the Account Entropy Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
message FilesFrame {
|
||||||
|
oneof item {
|
||||||
|
string mediaName = 1;
|
||||||
|
}
|
||||||
|
}
|
18
ts/CI.ts
18
ts/CI.ts
|
@ -43,6 +43,8 @@ export type CIType = {
|
||||||
) => unknown;
|
) => unknown;
|
||||||
openSignalRoute(url: string): Promise<void>;
|
openSignalRoute(url: string): Promise<void>;
|
||||||
migrateAllMessages(): Promise<void>;
|
migrateAllMessages(): Promise<void>;
|
||||||
|
exportLocalBackup(backupsBaseDir: string): Promise<string>;
|
||||||
|
stageLocalBackupForImport(snapshotDir: string): Promise<void>;
|
||||||
uploadBackup(): Promise<void>;
|
uploadBackup(): Promise<void>;
|
||||||
unlink: () => void;
|
unlink: () => void;
|
||||||
print: (...args: ReadonlyArray<unknown>) => void;
|
print: (...args: ReadonlyArray<unknown>) => void;
|
||||||
|
@ -193,6 +195,20 @@ export function getCI({
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportLocalBackup(backupsBaseDir: string): Promise<string> {
|
||||||
|
const { snapshotDir } =
|
||||||
|
await backupsService.exportLocalBackup(backupsBaseDir);
|
||||||
|
return snapshotDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageLocalBackupForImport(snapshotDir: string): Promise<void> {
|
||||||
|
const { error } =
|
||||||
|
await backupsService.stageLocalBackupForImport(snapshotDir);
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadBackup() {
|
async function uploadBackup() {
|
||||||
await backupsService.upload();
|
await backupsService.upload();
|
||||||
await AttachmentBackupManager.waitForIdle();
|
await AttachmentBackupManager.waitForIdle();
|
||||||
|
@ -237,6 +253,8 @@ export function getCI({
|
||||||
waitForEvent,
|
waitForEvent,
|
||||||
openSignalRoute,
|
openSignalRoute,
|
||||||
migrateAllMessages,
|
migrateAllMessages,
|
||||||
|
exportLocalBackup,
|
||||||
|
stageLocalBackupForImport,
|
||||||
uploadBackup,
|
uploadBackup,
|
||||||
unlink,
|
unlink,
|
||||||
getPendingEventCount,
|
getPendingEventCount,
|
||||||
|
|
|
@ -214,6 +214,7 @@ import { MessageModel } from './models/messages';
|
||||||
import { waitForEvent } from './shims/events';
|
import { waitForEvent } from './shims/events';
|
||||||
import { sendSyncRequests } from './textsecure/syncRequests';
|
import { sendSyncRequests } from './textsecure/syncRequests';
|
||||||
import { handleServerAlerts } from './util/handleServerAlerts';
|
import { handleServerAlerts } from './util/handleServerAlerts';
|
||||||
|
import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled';
|
||||||
|
|
||||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||||
|
@ -1758,7 +1759,7 @@ export async function startApp(): Promise<void> {
|
||||||
hasSentSyncRequests = true;
|
hasSentSyncRequests = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Download (or resume download) of link & sync backup
|
// 4. Download (or resume download) of link & sync backup or local backup
|
||||||
const { wasBackupImported } = await maybeDownloadAndImportBackup();
|
const { wasBackupImported } = await maybeDownloadAndImportBackup();
|
||||||
log.info(logId, {
|
log.info(logId, {
|
||||||
wasBackupImported,
|
wasBackupImported,
|
||||||
|
@ -1836,20 +1837,29 @@ export async function startApp(): Promise<void> {
|
||||||
wasBackupImported: boolean;
|
wasBackupImported: boolean;
|
||||||
}> {
|
}> {
|
||||||
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
||||||
if (backupDownloadPath) {
|
const isLocalBackupAvailable =
|
||||||
|
backupsService.isLocalBackupStaged() && isLocalBackupsEnabled();
|
||||||
|
|
||||||
|
if (isLocalBackupAvailable || backupDownloadPath) {
|
||||||
tapToViewMessagesDeletionService.pause();
|
tapToViewMessagesDeletionService.pause();
|
||||||
|
|
||||||
// Download backup before enabling request handler and storage service
|
// Download backup before enabling request handler and storage service
|
||||||
try {
|
try {
|
||||||
const { wasBackupImported } = await backupsService.downloadAndImport({
|
let wasBackupImported = false;
|
||||||
onProgress: (backupStep, currentBytes, totalBytes) => {
|
if (isLocalBackupAvailable) {
|
||||||
window.reduxActions.installer.updateBackupImportProgress({
|
await backupsService.importLocalBackup();
|
||||||
backupStep,
|
wasBackupImported = true;
|
||||||
currentBytes,
|
} else {
|
||||||
totalBytes,
|
({ wasBackupImported } = await backupsService.downloadAndImport({
|
||||||
});
|
onProgress: (backupStep, currentBytes, totalBytes) => {
|
||||||
},
|
window.reduxActions.installer.updateBackupImportProgress({
|
||||||
});
|
backupStep,
|
||||||
|
currentBytes,
|
||||||
|
totalBytes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
log.info('afterAppStart: backup download attempt completed, resolving');
|
log.info('afterAppStart: backup download attempt completed, resolving');
|
||||||
backupReady.resolve({ wasBackupImported });
|
backupReady.resolve({ wasBackupImported });
|
||||||
|
|
|
@ -43,6 +43,28 @@ const availableSpeakers = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const validateBackupResult = {
|
||||||
|
totalBytes: 100,
|
||||||
|
duration: 10000,
|
||||||
|
stats: {
|
||||||
|
adHocCalls: 1,
|
||||||
|
callLinks: 2,
|
||||||
|
conversations: 3,
|
||||||
|
chats: 4,
|
||||||
|
distributionLists: 5,
|
||||||
|
messages: 6,
|
||||||
|
notificationProfiles: 2,
|
||||||
|
skippedMessages: 7,
|
||||||
|
stickerPacks: 8,
|
||||||
|
fixedDirectMessages: 9,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportLocalBackupResult = {
|
||||||
|
...validateBackupResult,
|
||||||
|
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/Preferences',
|
title: 'Components/Preferences',
|
||||||
component: Preferences,
|
component: Preferences,
|
||||||
|
@ -138,6 +160,18 @@ export default {
|
||||||
doDeleteAllData: action('doDeleteAllData'),
|
doDeleteAllData: action('doDeleteAllData'),
|
||||||
doneRendering: action('doneRendering'),
|
doneRendering: action('doneRendering'),
|
||||||
editCustomColor: action('editCustomColor'),
|
editCustomColor: action('editCustomColor'),
|
||||||
|
exportLocalBackup: async () => {
|
||||||
|
return {
|
||||||
|
result: exportLocalBackupResult,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
importLocalBackup: async () => {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
error: undefined,
|
||||||
|
snapshotDir: exportLocalBackupResult.snapshotDir,
|
||||||
|
};
|
||||||
|
},
|
||||||
makeSyncRequest: action('makeSyncRequest'),
|
makeSyncRequest: action('makeSyncRequest'),
|
||||||
onAudioNotificationsChange: action('onAudioNotificationsChange'),
|
onAudioNotificationsChange: action('onAudioNotificationsChange'),
|
||||||
onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'),
|
onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'),
|
||||||
|
@ -192,22 +226,7 @@ export default {
|
||||||
),
|
),
|
||||||
validateBackup: async () => {
|
validateBackup: async () => {
|
||||||
return {
|
return {
|
||||||
result: {
|
result: validateBackupResult,
|
||||||
totalBytes: 100,
|
|
||||||
duration: 10000,
|
|
||||||
stats: {
|
|
||||||
adHocCalls: 1,
|
|
||||||
callLinks: 2,
|
|
||||||
conversations: 3,
|
|
||||||
chats: 4,
|
|
||||||
distributionLists: 5,
|
|
||||||
messages: 6,
|
|
||||||
notificationProfiles: 2,
|
|
||||||
skippedMessages: 7,
|
|
||||||
stickerPacks: 8,
|
|
||||||
fixedDirectMessages: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
} satisfies PropsType,
|
} satisfies PropsType,
|
||||||
|
|
|
@ -74,6 +74,7 @@ import {
|
||||||
import { PreferencesBackups } from './PreferencesBackups';
|
import { PreferencesBackups } from './PreferencesBackups';
|
||||||
import { PreferencesInternal } from './PreferencesInternal';
|
import { PreferencesInternal } from './PreferencesInternal';
|
||||||
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
|
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
|
||||||
|
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
|
||||||
|
|
||||||
type CheckboxChangeHandlerType = (value: boolean) => unknown;
|
type CheckboxChangeHandlerType = (value: boolean) => unknown;
|
||||||
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
|
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
|
||||||
|
@ -157,9 +158,11 @@ type PropsFunctionType = {
|
||||||
doDeleteAllData: () => unknown;
|
doDeleteAllData: () => unknown;
|
||||||
doneRendering: () => unknown;
|
doneRendering: () => unknown;
|
||||||
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
|
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
|
||||||
|
exportLocalBackup: () => Promise<BackupValidationResultType>;
|
||||||
getConversationsWithCustomColor: (
|
getConversationsWithCustomColor: (
|
||||||
colorId: string
|
colorId: string
|
||||||
) => Promise<Array<ConversationType>>;
|
) => Promise<Array<ConversationType>>;
|
||||||
|
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
|
||||||
makeSyncRequest: () => unknown;
|
makeSyncRequest: () => unknown;
|
||||||
refreshCloudBackupStatus: () => void;
|
refreshCloudBackupStatus: () => void;
|
||||||
refreshBackupSubscriptionStatus: () => void;
|
refreshBackupSubscriptionStatus: () => void;
|
||||||
|
@ -286,6 +289,7 @@ export function Preferences({
|
||||||
doneRendering,
|
doneRendering,
|
||||||
editCustomColor,
|
editCustomColor,
|
||||||
emojiSkinToneDefault,
|
emojiSkinToneDefault,
|
||||||
|
exportLocalBackup,
|
||||||
getConversationsWithCustomColor,
|
getConversationsWithCustomColor,
|
||||||
hasAudioNotifications,
|
hasAudioNotifications,
|
||||||
hasAutoConvertEmoji,
|
hasAutoConvertEmoji,
|
||||||
|
@ -311,6 +315,7 @@ export function Preferences({
|
||||||
hasTextFormatting,
|
hasTextFormatting,
|
||||||
hasTypingIndicators,
|
hasTypingIndicators,
|
||||||
i18n,
|
i18n,
|
||||||
|
importLocalBackup,
|
||||||
initialPage = Page.General,
|
initialPage = Page.General,
|
||||||
initialSpellCheckSetting,
|
initialSpellCheckSetting,
|
||||||
isAutoDownloadUpdatesSupported,
|
isAutoDownloadUpdatesSupported,
|
||||||
|
@ -1736,7 +1741,12 @@ export function Preferences({
|
||||||
);
|
);
|
||||||
} else if (page === Page.Internal) {
|
} else if (page === Page.Internal) {
|
||||||
settings = (
|
settings = (
|
||||||
<PreferencesInternal i18n={i18n} validateBackup={validateBackup} />
|
<PreferencesInternal
|
||||||
|
i18n={i18n}
|
||||||
|
exportLocalBackup={exportLocalBackup}
|
||||||
|
importLocalBackup={importLocalBackup}
|
||||||
|
validateBackup={validateBackup}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,17 +7,30 @@ import { toLogFormat } from '../types/errors';
|
||||||
import { formatFileSize } from '../util/formatFileSize';
|
import { formatFileSize } from '../util/formatFileSize';
|
||||||
import { SECOND } from '../util/durations';
|
import { SECOND } from '../util/durations';
|
||||||
import type { ValidationResultType as BackupValidationResultType } from '../services/backups';
|
import type { ValidationResultType as BackupValidationResultType } from '../services/backups';
|
||||||
|
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
|
||||||
import { SettingsRow, SettingsControl } from './PreferencesUtil';
|
import { SettingsRow, SettingsControl } from './PreferencesUtil';
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
import { Spinner } from './Spinner';
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
export function PreferencesInternal({
|
export function PreferencesInternal({
|
||||||
i18n,
|
i18n,
|
||||||
|
exportLocalBackup: doExportLocalBackup,
|
||||||
|
importLocalBackup: doImportLocalBackup,
|
||||||
validateBackup: doValidateBackup,
|
validateBackup: doValidateBackup,
|
||||||
}: {
|
}: {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
exportLocalBackup: () => Promise<BackupValidationResultType>;
|
||||||
|
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
|
||||||
validateBackup: () => Promise<BackupValidationResultType>;
|
validateBackup: () => Promise<BackupValidationResultType>;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const [isExportPending, setIsExportPending] = useState(false);
|
||||||
|
const [exportResult, setExportResult] = useState<
|
||||||
|
BackupValidationResultType | undefined
|
||||||
|
>();
|
||||||
|
const [importResult, setImportResult] = useState<
|
||||||
|
ValidateLocalBackupStructureResultType | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const [isValidationPending, setIsValidationPending] = useState(false);
|
const [isValidationPending, setIsValidationPending] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<
|
const [validationResult, setValidationResult] = useState<
|
||||||
BackupValidationResultType | undefined
|
BackupValidationResultType | undefined
|
||||||
|
@ -35,34 +48,110 @@ export function PreferencesInternal({
|
||||||
}
|
}
|
||||||
}, [doValidateBackup]);
|
}, [doValidateBackup]);
|
||||||
|
|
||||||
let validationElem: JSX.Element | undefined;
|
const renderValidationResult = useCallback(
|
||||||
if (validationResult != null) {
|
(
|
||||||
if ('result' in validationResult) {
|
backupResult: BackupValidationResultType | undefined
|
||||||
const {
|
): JSX.Element | undefined => {
|
||||||
result: { totalBytes, stats, duration },
|
if (backupResult == null) {
|
||||||
} = validationResult;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
validationElem = (
|
if ('result' in backupResult) {
|
||||||
<div className="Preferences--internal--validate-backup--result">
|
const {
|
||||||
<p>File size: {formatFileSize(totalBytes)}</p>
|
result: { totalBytes, stats, duration },
|
||||||
<p>Duration: {Math.round(duration / SECOND)}s</p>
|
} = backupResult;
|
||||||
<pre>
|
|
||||||
<code>{JSON.stringify(stats, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const { error } = validationResult;
|
|
||||||
|
|
||||||
validationElem = (
|
let snapshotDirEl: JSX.Element | undefined;
|
||||||
|
if ('snapshotDir' in backupResult.result) {
|
||||||
|
snapshotDirEl = (
|
||||||
|
<p>
|
||||||
|
Backup path:
|
||||||
|
<pre>
|
||||||
|
<code>{backupResult.result.snapshotDir}</code>
|
||||||
|
</pre>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Preferences--internal--validate-backup--result">
|
||||||
|
{snapshotDirEl}
|
||||||
|
<p>Main file size: {formatFileSize(totalBytes)}</p>
|
||||||
|
<p>Duration: {Math.round(duration / SECOND)}s</p>
|
||||||
|
<pre>
|
||||||
|
<code>{JSON.stringify(stats, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = backupResult;
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="Preferences--internal--validate-backup--error">
|
<div className="Preferences--internal--validate-backup--error">
|
||||||
<pre>
|
<pre>
|
||||||
<code>{error}</code>
|
<code>{error}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportLocalBackup = useCallback(async () => {
|
||||||
|
setIsExportPending(true);
|
||||||
|
setExportResult(undefined);
|
||||||
|
try {
|
||||||
|
setExportResult(await doExportLocalBackup());
|
||||||
|
} catch (error) {
|
||||||
|
setExportResult({ error: toLogFormat(error) });
|
||||||
|
} finally {
|
||||||
|
setIsExportPending(false);
|
||||||
}
|
}
|
||||||
}
|
}, [doExportLocalBackup]);
|
||||||
|
|
||||||
|
const importLocalBackup = useCallback(async () => {
|
||||||
|
setImportResult(undefined);
|
||||||
|
try {
|
||||||
|
setImportResult(await doImportLocalBackup());
|
||||||
|
} catch (error) {
|
||||||
|
setImportResult({
|
||||||
|
success: false,
|
||||||
|
error: toLogFormat(error),
|
||||||
|
snapshotDir: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [doImportLocalBackup]);
|
||||||
|
|
||||||
|
const renderImportResult = useCallback(
|
||||||
|
(
|
||||||
|
didImportResult: ValidateLocalBackupStructureResultType | undefined
|
||||||
|
): JSX.Element | undefined => {
|
||||||
|
if (didImportResult == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success, error, snapshotDir } = didImportResult;
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="Preferences--internal--validate-backup--result">
|
||||||
|
<pre>
|
||||||
|
<code>{`Staged: ${snapshotDir}\n\nPlease link to finish import.`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Preferences--internal--validate-backup--error">
|
||||||
|
<pre>
|
||||||
|
<code>{`Failed: ${error}`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -93,7 +182,49 @@ export function PreferencesInternal({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{validationElem}
|
{renderValidationResult(validationResult)}
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
className="Preferences--internal--backups"
|
||||||
|
title={i18n('icu:Preferences__internal__local-backups')}
|
||||||
|
>
|
||||||
|
<SettingsControl
|
||||||
|
left={i18n(
|
||||||
|
'icu:Preferences__internal__export-local-backup--description'
|
||||||
|
)}
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
variant={ButtonVariant.Secondary}
|
||||||
|
onClick={exportLocalBackup}
|
||||||
|
disabled={isExportPending}
|
||||||
|
>
|
||||||
|
{isExportPending ? (
|
||||||
|
<Spinner size="22px" svgSize="small" />
|
||||||
|
) : (
|
||||||
|
i18n('icu:Preferences__internal__export-local-backup')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderValidationResult(exportResult)}
|
||||||
|
|
||||||
|
<SettingsControl
|
||||||
|
left={i18n(
|
||||||
|
'icu:Preferences__internal__import-local-backup--description'
|
||||||
|
)}
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
variant={ButtonVariant.Secondary}
|
||||||
|
onClick={importLocalBackup}
|
||||||
|
>
|
||||||
|
{i18n('icu:Preferences__internal__import-local-backup')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderImportResult(importResult)}
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
AttachmentVariant,
|
AttachmentVariant,
|
||||||
AttachmentPermanentlyUndownloadableError,
|
AttachmentPermanentlyUndownloadableError,
|
||||||
mightBeOnBackupTier,
|
mightBeOnBackupTier,
|
||||||
|
mightBeInLocalBackup,
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
import { type ReadonlyMessageAttributesType } from '../model-types.d';
|
import { type ReadonlyMessageAttributesType } from '../model-types.d';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
|
@ -285,9 +286,10 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
||||||
// try to download from the transit tier (or it's an invalid attachment, etc.). We
|
// try to download from the transit tier (or it's an invalid attachment, etc.). We
|
||||||
// may need to extend the attachment_downloads table in the future to better
|
// may need to extend the attachment_downloads table in the future to better
|
||||||
// differentiate source vs. location.
|
// differentiate source vs. location.
|
||||||
source: mightBeOnBackupTier(attachment)
|
source:
|
||||||
? source
|
mightBeOnBackupTier(attachment) || mightBeInLocalBackup(attachment)
|
||||||
: AttachmentDownloadSource.STANDARD,
|
? source
|
||||||
|
: AttachmentDownloadSource.STANDARD,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
|
@ -462,7 +464,10 @@ async function runDownloadAttachmentJob({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mightBeOnBackupTier(job.attachment)) {
|
if (
|
||||||
|
mightBeOnBackupTier(job.attachment) ||
|
||||||
|
mightBeInLocalBackup(job.attachment)
|
||||||
|
) {
|
||||||
const currentDownloadedSize =
|
const currentDownloadedSize =
|
||||||
window.storage.get('backupMediaDownloadCompletedBytes') ?? 0;
|
window.storage.get('backupMediaDownloadCompletedBytes') ?? 0;
|
||||||
drop(
|
drop(
|
||||||
|
@ -615,7 +620,8 @@ export async function runDownloadAttachmentJobInner({
|
||||||
isForCurrentlyVisibleMessage &&
|
isForCurrentlyVisibleMessage &&
|
||||||
mightHaveThumbnailOnBackupTier(job.attachment) &&
|
mightHaveThumbnailOnBackupTier(job.attachment) &&
|
||||||
// TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job
|
// TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job
|
||||||
!job.attachment.thumbnailFromBackup;
|
!job.attachment.thumbnailFromBackup &&
|
||||||
|
!mightBeInLocalBackup(attachment);
|
||||||
|
|
||||||
if (preferBackupThumbnail) {
|
if (preferBackupThumbnail) {
|
||||||
logId += '.preferringBackupThumbnail';
|
logId += '.preferringBackupThumbnail';
|
||||||
|
@ -811,7 +817,9 @@ async function downloadBackupThumbnail({
|
||||||
}: {
|
}: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
|
dependencies: {
|
||||||
|
downloadAttachment: typeof downloadAttachmentUtil;
|
||||||
|
};
|
||||||
}): Promise<AttachmentType> {
|
}): Promise<AttachmentType> {
|
||||||
const downloadedThumbnail = await dependencies.downloadAttachment({
|
const downloadedThumbnail = await dependencies.downloadAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
|
|
279
ts/jobs/AttachmentLocalBackupManager.ts
Normal file
279
ts/jobs/AttachmentLocalBackupManager.ts
Normal file
|
@ -0,0 +1,279 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { constants as FS_CONSTANTS, copyFile, mkdir } from 'fs/promises';
|
||||||
|
|
||||||
|
import * as durations from '../util/durations';
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
import { redactGenericText } from '../util/privacy';
|
||||||
|
import {
|
||||||
|
JobManager,
|
||||||
|
type JobManagerParamsType,
|
||||||
|
type JobManagerJobResultType,
|
||||||
|
} from './JobManager';
|
||||||
|
import { type BackupsService, backupsService } from '../services/backups';
|
||||||
|
import { decryptAttachmentV2ToSink } from '../AttachmentCrypto';
|
||||||
|
import {
|
||||||
|
type AttachmentLocalBackupJobType,
|
||||||
|
type CoreAttachmentLocalBackupJobType,
|
||||||
|
} from '../types/AttachmentBackup';
|
||||||
|
import { isInCall as isInCallSelector } from '../state/selectors/calling';
|
||||||
|
import { encryptAndUploadAttachment } from '../util/uploadAttachment';
|
||||||
|
import type { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
import type { LocallySavedAttachment } from '../types/Attachment';
|
||||||
|
import {
|
||||||
|
getLocalBackupDirectoryForMediaName,
|
||||||
|
getLocalBackupPathForMediaName,
|
||||||
|
} from '../services/backups/util/localBackup';
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_JOBS = 3;
|
||||||
|
const RETRY_CONFIG = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
backoffConfig: {
|
||||||
|
// 1 minute, 5 minutes, 25 minutes, every hour
|
||||||
|
multiplier: 3,
|
||||||
|
firstBackoffs: [10 * durations.SECOND],
|
||||||
|
maxBackoffTime: durations.MINUTE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AttachmentLocalBackupManager extends JobManager<CoreAttachmentLocalBackupJobType> {
|
||||||
|
static #instance: AttachmentLocalBackupManager | undefined;
|
||||||
|
readonly #jobsByMediaName = new Map<string, AttachmentLocalBackupJobType>();
|
||||||
|
|
||||||
|
static defaultParams: JobManagerParamsType<CoreAttachmentLocalBackupJobType> =
|
||||||
|
{
|
||||||
|
markAllJobsInactive: AttachmentLocalBackupManager.markAllJobsInactive,
|
||||||
|
saveJob: AttachmentLocalBackupManager.saveJob,
|
||||||
|
removeJob: AttachmentLocalBackupManager.removeJob,
|
||||||
|
getNextJobs: AttachmentLocalBackupManager.getNextJobs,
|
||||||
|
runJob: runAttachmentBackupJob,
|
||||||
|
shouldHoldOffOnStartingQueuedJobs: () => {
|
||||||
|
const reduxState = window.reduxStore?.getState();
|
||||||
|
if (reduxState) {
|
||||||
|
return isInCallSelector(reduxState);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
getJobId,
|
||||||
|
getJobIdForLogging,
|
||||||
|
getRetryConfig: () => RETRY_CONFIG,
|
||||||
|
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
|
||||||
|
};
|
||||||
|
|
||||||
|
override logPrefix = 'AttachmentLocalBackupManager';
|
||||||
|
|
||||||
|
static get instance(): AttachmentLocalBackupManager {
|
||||||
|
if (!AttachmentLocalBackupManager.#instance) {
|
||||||
|
AttachmentLocalBackupManager.#instance = new AttachmentLocalBackupManager(
|
||||||
|
AttachmentLocalBackupManager.defaultParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AttachmentLocalBackupManager.#instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get jobs(): Map<string, AttachmentLocalBackupJobType> {
|
||||||
|
return AttachmentLocalBackupManager.instance.#jobsByMediaName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async start(): Promise<void> {
|
||||||
|
log.info('AttachmentLocalBackupManager/starting');
|
||||||
|
await AttachmentLocalBackupManager.instance.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async stop(): Promise<void> {
|
||||||
|
log.info('AttachmentLocalBackupManager/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)}.${job.type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup-specific methods
|
||||||
|
*/
|
||||||
|
class AttachmentPermanentlyMissingError extends Error {}
|
||||||
|
|
||||||
|
type RunAttachmentBackupJobDependenciesType = {
|
||||||
|
getAbsoluteAttachmentPath: typeof window.Signal.Migrations.getAbsoluteAttachmentPath;
|
||||||
|
backupMediaBatch?: WebAPIType['backupMediaBatch'];
|
||||||
|
backupsService: BackupsService;
|
||||||
|
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
|
||||||
|
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runAttachmentBackupJob(
|
||||||
|
job: AttachmentLocalBackupJobType,
|
||||||
|
_options: {
|
||||||
|
isLastAttempt: boolean;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
},
|
||||||
|
dependencies: RunAttachmentBackupJobDependenciesType = {
|
||||||
|
getAbsoluteAttachmentPath:
|
||||||
|
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||||
|
backupsService,
|
||||||
|
backupMediaBatch: window.textsecure.server?.backupMediaBatch,
|
||||||
|
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(
|
||||||
|
job: AttachmentLocalBackupJobType,
|
||||||
|
dependencies: RunAttachmentBackupJobDependenciesType
|
||||||
|
): Promise<void> {
|
||||||
|
const jobIdForLogging = getJobIdForLogging(job);
|
||||||
|
const logId = `AttachmentLocalBackupManager.runAttachmentBackupJobInner(${jobIdForLogging})`;
|
||||||
|
|
||||||
|
log.info(`${logId}: starting`);
|
||||||
|
|
||||||
|
const { backupsBaseDir, mediaName } = job;
|
||||||
|
const { contentType, digest, iv, keys, localKey, path, size } = job.data;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw new AttachmentPermanentlyMissingError('No path property');
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = dependencies.getAbsoluteAttachmentPath(path);
|
||||||
|
if (!existsSync(absolutePath)) {
|
||||||
|
throw new AttachmentPermanentlyMissingError('No file at provided path');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localKey) {
|
||||||
|
throw new Error('No localKey property, required for test decryption');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localBackupFileDir = getLocalBackupDirectoryForMediaName({
|
||||||
|
backupsBaseDir,
|
||||||
|
mediaName,
|
||||||
|
});
|
||||||
|
await mkdir(localBackupFileDir, { recursive: true });
|
||||||
|
|
||||||
|
const localBackupFilePath = getLocalBackupPathForMediaName({
|
||||||
|
backupsBaseDir,
|
||||||
|
mediaName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment: LocallySavedAttachment = {
|
||||||
|
path,
|
||||||
|
iv,
|
||||||
|
key: keys,
|
||||||
|
localKey,
|
||||||
|
digest,
|
||||||
|
contentType,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Add check in local FS to prevent double backup
|
||||||
|
|
||||||
|
// File is already encrypted with localKey, so we just have to copy it to the backup dir
|
||||||
|
const attachmentPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
|
||||||
|
attachment.path
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy)
|
||||||
|
await copyFile(
|
||||||
|
attachmentPath,
|
||||||
|
localBackupFilePath,
|
||||||
|
FS_CONSTANTS.COPYFILE_FICLONE
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Optimize this check -- it can be expensive to test decrypt on every export
|
||||||
|
log.info(`${logId}: Verifying file restored from local backup`);
|
||||||
|
const sink = new PassThrough();
|
||||||
|
sink.resume();
|
||||||
|
await decryptAttachmentV2ToSink(
|
||||||
|
{
|
||||||
|
ciphertextPath: localBackupFilePath,
|
||||||
|
idForLogging: 'attachments/readAndDecryptDataFromDisk',
|
||||||
|
keysBase64: localKey,
|
||||||
|
size,
|
||||||
|
type: 'local',
|
||||||
|
},
|
||||||
|
sink
|
||||||
|
);
|
||||||
|
}
|
|
@ -68,6 +68,8 @@ export class SettingsChannel extends EventEmitter {
|
||||||
this.#installCallback('syncRequest');
|
this.#installCallback('syncRequest');
|
||||||
this.#installCallback('setEmojiSkinToneDefault');
|
this.#installCallback('setEmojiSkinToneDefault');
|
||||||
this.#installCallback('getEmojiSkinToneDefault');
|
this.#installCallback('getEmojiSkinToneDefault');
|
||||||
|
this.#installCallback('exportLocalBackup');
|
||||||
|
this.#installCallback('importLocalBackup');
|
||||||
this.#installCallback('validateBackup');
|
this.#installCallback('validateBackup');
|
||||||
|
|
||||||
// Backups
|
// Backups
|
||||||
|
|
|
@ -6,6 +6,10 @@ import type { ConversationColorType } from '../../types/Colors';
|
||||||
|
|
||||||
export const BACKUP_VERSION = 1;
|
export const BACKUP_VERSION = 1;
|
||||||
|
|
||||||
|
export const LOCAL_BACKUP_VERSION = 1;
|
||||||
|
|
||||||
|
export const LOCAL_BACKUP_BACKUP_ID_IV_LENGTH = 16;
|
||||||
|
|
||||||
const { WallpaperPreset } = Backups.ChatStyle;
|
const { WallpaperPreset } = Backups.ChatStyle;
|
||||||
|
|
||||||
// See https://github.com/signalapp/Signal-Android-Private/blob/4a41e9f9a1ed0aba7cae0e0dc4dbcac50fddc469/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt#L32
|
// See https://github.com/signalapp/Signal-Android-Private/blob/4a41e9f9a1ed0aba7cae0e0dc4dbcac50fddc469/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt#L32
|
||||||
|
|
|
@ -125,3 +125,12 @@ export function deriveBackupThumbnailTransitKeyMaterial(
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBackupId(): Uint8Array {
|
||||||
|
const aci = window.storage.user.getCheckedAci();
|
||||||
|
return getBackupKey().deriveBackupId(toAciObject(aci));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBackupMetadataKey(): Uint8Array {
|
||||||
|
return getBackupKey().deriveLocalBackupMetadataKey();
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,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 type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
|
import { dirname } from 'path';
|
||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import pTimeout from 'p-timeout';
|
import pTimeout from 'p-timeout';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
@ -125,11 +126,13 @@ import {
|
||||||
} from '../../types/Attachment';
|
} from '../../types/Attachment';
|
||||||
import {
|
import {
|
||||||
getFilePointerForAttachment,
|
getFilePointerForAttachment,
|
||||||
|
getLocalBackupFilePointerForAttachment,
|
||||||
maybeGetBackupJobForAttachmentAndFilePointer,
|
maybeGetBackupJobForAttachmentAndFilePointer,
|
||||||
} from './util/filePointers';
|
} from './util/filePointers';
|
||||||
import { getBackupMediaRootKey } from './crypto';
|
import { getBackupMediaRootKey } from './crypto';
|
||||||
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
|
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
|
||||||
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
|
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
|
||||||
|
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager';
|
||||||
import { getBackupCdnInfo } from './util/mediaId';
|
import { getBackupCdnInfo } from './util/mediaId';
|
||||||
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
|
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
@ -177,6 +180,7 @@ type ToChatItemOptionsType = Readonly<{
|
||||||
aboutMe: AboutMe;
|
aboutMe: AboutMe;
|
||||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type NonBubbleOptionsType = Pick<
|
type NonBubbleOptionsType = Pick<
|
||||||
|
@ -227,6 +231,7 @@ export class BackupExportStream extends Readable {
|
||||||
readonly #serviceIdToRecipientId = new Map<string, number>();
|
readonly #serviceIdToRecipientId = new Map<string, number>();
|
||||||
readonly #e164ToRecipientId = new Map<string, number>();
|
readonly #e164ToRecipientId = new Map<string, number>();
|
||||||
readonly #roomIdToRecipientId = new Map<string, number>();
|
readonly #roomIdToRecipientId = new Map<string, number>();
|
||||||
|
readonly #mediaNamesToFilePointers = new Map<string, Backups.FilePointer>();
|
||||||
readonly #stats: StatsType = {
|
readonly #stats: StatsType = {
|
||||||
adHocCalls: 0,
|
adHocCalls: 0,
|
||||||
callLinks: 0,
|
callLinks: 0,
|
||||||
|
@ -253,43 +258,82 @@ export class BackupExportStream extends Readable {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public run(backupLevel: BackupLevel): void {
|
public run(
|
||||||
|
backupLevel: BackupLevel,
|
||||||
|
localBackupSnapshotDir: string | undefined = undefined
|
||||||
|
): void {
|
||||||
|
const localBackupsBaseDir = localBackupSnapshotDir
|
||||||
|
? dirname(localBackupSnapshotDir)
|
||||||
|
: undefined;
|
||||||
|
const isLocalBackup = localBackupsBaseDir != null;
|
||||||
drop(
|
drop(
|
||||||
(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();
|
||||||
|
|
||||||
await pauseWriteAccess();
|
await pauseWriteAccess();
|
||||||
try {
|
try {
|
||||||
await this.#unsafeRun(backupLevel);
|
await this.#unsafeRun(backupLevel, isLocalBackup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
} finally {
|
} finally {
|
||||||
await resumeWriteAccess();
|
await resumeWriteAccess();
|
||||||
|
|
||||||
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
|
if (isLocalBackup) {
|
||||||
await DataWriter.clearAllAttachmentBackupJobs();
|
log.info(
|
||||||
if (this.backupType !== BackupType.TestOnlyPlaintext) {
|
`BackupExportStream: Adding ${this.#attachmentBackupJobs.length} jobs for AttachmentLocalBackupManager`
|
||||||
await Promise.all(
|
|
||||||
this.#attachmentBackupJobs.map(job =>
|
|
||||||
AttachmentBackupManager.addJobAndMaybeThumbnailJob(job)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
drop(AttachmentBackupManager.start());
|
AttachmentLocalBackupManager.clearAllJobs();
|
||||||
|
await Promise.all(
|
||||||
|
this.#attachmentBackupJobs.map(job => {
|
||||||
|
if (job.type === 'thumbnail') {
|
||||||
|
log.error(
|
||||||
|
"BackupExportStream: Can't backup thumbnails to local backup, skipping"
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentLocalBackupManager.addJob({
|
||||||
|
...job,
|
||||||
|
backupsBaseDir: localBackupsBaseDir,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
drop(AttachmentLocalBackupManager.start());
|
||||||
|
} else {
|
||||||
|
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
|
||||||
|
await DataWriter.clearAllAttachmentBackupJobs();
|
||||||
|
if (this.backupType !== BackupType.TestOnlyPlaintext) {
|
||||||
|
await Promise.all(
|
||||||
|
this.#attachmentBackupJobs.map(job =>
|
||||||
|
AttachmentBackupManager.addJobAndMaybeThumbnailJob(job)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
drop(AttachmentBackupManager.start());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('BackupExportStream: finished');
|
log.info('BackupExportStream: finished');
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMediaNamesIterator(): MapIterator<string> {
|
||||||
|
return this.#mediaNamesToFilePointers.keys();
|
||||||
|
}
|
||||||
|
|
||||||
public getStats(): Readonly<StatsType> {
|
public getStats(): Readonly<StatsType> {
|
||||||
return this.#stats;
|
return this.#stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #unsafeRun(backupLevel: BackupLevel): Promise<void> {
|
async #unsafeRun(
|
||||||
|
backupLevel: BackupLevel,
|
||||||
|
isLocalBackup: boolean
|
||||||
|
): Promise<void> {
|
||||||
this.#ourConversation =
|
this.#ourConversation =
|
||||||
window.ConversationController.getOurConversationOrThrow().attributes;
|
window.ConversationController.getOurConversationOrThrow().attributes;
|
||||||
this.push(
|
this.push(
|
||||||
|
@ -663,6 +707,7 @@ export class BackupExportStream extends Readable {
|
||||||
aboutMe,
|
aboutMe,
|
||||||
callHistoryByCallId,
|
callHistoryByCallId,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}),
|
}),
|
||||||
{ concurrency: MAX_CONCURRENCY }
|
{ concurrency: MAX_CONCURRENCY }
|
||||||
);
|
);
|
||||||
|
@ -1093,7 +1138,12 @@ export class BackupExportStream extends Readable {
|
||||||
|
|
||||||
async #toChatItem(
|
async #toChatItem(
|
||||||
message: MessageAttributesType,
|
message: MessageAttributesType,
|
||||||
{ aboutMe, callHistoryByCallId, backupLevel }: ToChatItemOptionsType
|
{
|
||||||
|
aboutMe,
|
||||||
|
callHistoryByCallId,
|
||||||
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
|
}: ToChatItemOptionsType
|
||||||
): Promise<Backups.IChatItem | undefined> {
|
): Promise<Backups.IChatItem | undefined> {
|
||||||
const conversation = window.ConversationController.get(
|
const conversation = window.ConversationController.get(
|
||||||
message.conversationId
|
message.conversationId
|
||||||
|
@ -1253,6 +1303,7 @@ export class BackupExportStream extends Readable {
|
||||||
result.viewOnceMessage = await this.#toViewOnceMessage({
|
result.viewOnceMessage = await this.#toViewOnceMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
} else if (message.deletedForEveryone) {
|
} else if (message.deletedForEveryone) {
|
||||||
result.remoteDeletedMessage = {};
|
result.remoteDeletedMessage = {};
|
||||||
|
@ -1314,6 +1365,7 @@ export class BackupExportStream extends Readable {
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: contactDetails.avatar.avatar,
|
attachment: contactDetails.avatar.avatar,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -1335,6 +1387,7 @@ export class BackupExportStream extends Readable {
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: sticker.data,
|
attachment: sticker.data,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
@ -1378,23 +1431,27 @@ export class BackupExportStream extends Readable {
|
||||||
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
|
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
|
|
||||||
result.revisions = await this.#toChatItemRevisions(
|
result.revisions = await this.#toChatItemRevisions(
|
||||||
result,
|
result,
|
||||||
message,
|
message,
|
||||||
backupLevel
|
backupLevel,
|
||||||
|
isLocalBackup
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result.standardMessage = await this.#toStandardMessage({
|
result.standardMessage = await this.#toStandardMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
|
|
||||||
result.revisions = await this.#toChatItemRevisions(
|
result.revisions = await this.#toChatItemRevisions(
|
||||||
result,
|
result,
|
||||||
message,
|
message,
|
||||||
backupLevel
|
backupLevel,
|
||||||
|
isLocalBackup
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2297,9 +2354,11 @@ export class BackupExportStream extends Readable {
|
||||||
async #toQuote({
|
async #toQuote({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}: {
|
}: {
|
||||||
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
|
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.IQuote | null> {
|
}): Promise<Backups.IQuote | null> {
|
||||||
const { quote } = message;
|
const { quote } = message;
|
||||||
if (!quote) {
|
if (!quote) {
|
||||||
|
@ -2359,6 +2418,7 @@ export class BackupExportStream extends Readable {
|
||||||
attachment: attachment.thumbnail,
|
attachment: attachment.thumbnail,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
message,
|
message,
|
||||||
|
isLocalBackup,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
@ -2417,16 +2477,19 @@ export class BackupExportStream extends Readable {
|
||||||
attachment,
|
attachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
message,
|
message,
|
||||||
|
isLocalBackup,
|
||||||
}: {
|
}: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
|
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.MessageAttachment> {
|
}): Promise<Backups.MessageAttachment> {
|
||||||
const { clientUuid } = attachment;
|
const { clientUuid } = attachment;
|
||||||
const filePointer = await this.#processAttachment({
|
const filePointer = await this.#processAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Backups.MessageAttachment({
|
return new Backups.MessageAttachment({
|
||||||
|
@ -2440,18 +2503,51 @@ export class BackupExportStream extends Readable {
|
||||||
async #processAttachment({
|
async #processAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt,
|
messageReceivedAt,
|
||||||
}: {
|
}: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
messageReceivedAt: number;
|
messageReceivedAt: number;
|
||||||
}): Promise<Backups.FilePointer> {
|
}): Promise<Backups.FilePointer> {
|
||||||
const { filePointer, updatedAttachment } =
|
// We need to always get updatedAttachment in case the attachment wasn't reencryptable
|
||||||
await getFilePointerForAttachment({
|
// to the original digest. In that case mediaName will be based on updatedAttachment.
|
||||||
attachment,
|
const { filePointer, updatedAttachment } = isLocalBackup
|
||||||
backupLevel,
|
? await getLocalBackupFilePointerForAttachment({
|
||||||
getBackupCdnInfo,
|
attachment,
|
||||||
});
|
backupLevel,
|
||||||
|
getBackupCdnInfo,
|
||||||
|
})
|
||||||
|
: await getFilePointerForAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
getBackupCdnInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLocalBackup && filePointer.localLocator) {
|
||||||
|
// Duplicate attachment check. Local backups can only contain 1 file per mediaName,
|
||||||
|
// so if we see a duplicate mediaName then we must reuse the previous FilePointer.
|
||||||
|
const { mediaName } = filePointer.localLocator;
|
||||||
|
strictAssert(
|
||||||
|
mediaName,
|
||||||
|
'FilePointer.LocalLocator must contain mediaName'
|
||||||
|
);
|
||||||
|
const existingFilePointer = this.#mediaNamesToFilePointers.get(mediaName);
|
||||||
|
if (existingFilePointer) {
|
||||||
|
strictAssert(
|
||||||
|
existingFilePointer.localLocator,
|
||||||
|
'Local backup existing mediaName FilePointer must contain LocalLocator'
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
existingFilePointer.localLocator.size === attachment.size,
|
||||||
|
'Local backup existing mediaName FilePointer size must match attachment'
|
||||||
|
);
|
||||||
|
return existingFilePointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#mediaNamesToFilePointers.set(mediaName, filePointer);
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedAttachment) {
|
if (updatedAttachment) {
|
||||||
// TODO (DESKTOP-6688): ensure that we update the message/attachment in DB with the
|
// TODO (DESKTOP-6688): ensure that we update the message/attachment in DB with the
|
||||||
|
@ -2639,6 +2735,7 @@ export class BackupExportStream extends Readable {
|
||||||
async #toStandardMessage({
|
async #toStandardMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}: {
|
}: {
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
@ -2652,11 +2749,13 @@ export class BackupExportStream extends Readable {
|
||||||
| 'received_at'
|
| 'received_at'
|
||||||
>;
|
>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.IStandardMessage> {
|
}): Promise<Backups.IStandardMessage> {
|
||||||
return {
|
return {
|
||||||
quote: await this.#toQuote({
|
quote: await this.#toQuote({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}),
|
}),
|
||||||
attachments: message.attachments?.length
|
attachments: message.attachments?.length
|
||||||
? await Promise.all(
|
? await Promise.all(
|
||||||
|
@ -2665,6 +2764,7 @@ export class BackupExportStream extends Readable {
|
||||||
attachment,
|
attachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
message,
|
message,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -2673,6 +2773,7 @@ export class BackupExportStream extends Readable {
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: message.bodyAttachment,
|
attachment: message.bodyAttachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -2697,6 +2798,7 @@ export class BackupExportStream extends Readable {
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: preview.image,
|
attachment: preview.image,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -2711,6 +2813,7 @@ export class BackupExportStream extends Readable {
|
||||||
async #toDirectStoryReplyMessage({
|
async #toDirectStoryReplyMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}: {
|
}: {
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
@ -2722,6 +2825,7 @@ export class BackupExportStream extends Readable {
|
||||||
| 'reactions'
|
| 'reactions'
|
||||||
>;
|
>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.IDirectStoryReplyMessage> {
|
}): Promise<Backups.IDirectStoryReplyMessage> {
|
||||||
const result = new Backups.DirectStoryReplyMessage({
|
const result = new Backups.DirectStoryReplyMessage({
|
||||||
reactions: this.#getMessageReactions(message),
|
reactions: this.#getMessageReactions(message),
|
||||||
|
@ -2735,6 +2839,7 @@ export class BackupExportStream extends Readable {
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: message.bodyAttachment,
|
attachment: message.bodyAttachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
messageReceivedAt: message.received_at,
|
messageReceivedAt: message.received_at,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -2755,12 +2860,14 @@ export class BackupExportStream extends Readable {
|
||||||
async #toViewOnceMessage({
|
async #toViewOnceMessage({
|
||||||
message,
|
message,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
}: {
|
}: {
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
'attachments' | 'received_at' | 'reactions'
|
'attachments' | 'received_at' | 'reactions'
|
||||||
>;
|
>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.IViewOnceMessage> {
|
}): Promise<Backups.IViewOnceMessage> {
|
||||||
const attachment = message.attachments?.at(0);
|
const attachment = message.attachments?.at(0);
|
||||||
return {
|
return {
|
||||||
|
@ -2771,6 +2878,7 @@ export class BackupExportStream extends Readable {
|
||||||
attachment,
|
attachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
message,
|
message,
|
||||||
|
isLocalBackup,
|
||||||
}),
|
}),
|
||||||
reactions: this.#getMessageReactions(message),
|
reactions: this.#getMessageReactions(message),
|
||||||
};
|
};
|
||||||
|
@ -2779,7 +2887,8 @@ export class BackupExportStream extends Readable {
|
||||||
async #toChatItemRevisions(
|
async #toChatItemRevisions(
|
||||||
parent: Backups.IChatItem,
|
parent: Backups.IChatItem,
|
||||||
message: MessageAttributesType,
|
message: MessageAttributesType,
|
||||||
backupLevel: BackupLevel
|
backupLevel: BackupLevel,
|
||||||
|
isLocalBackup: boolean
|
||||||
): Promise<Array<Backups.IChatItem> | undefined> {
|
): Promise<Array<Backups.IChatItem> | undefined> {
|
||||||
const { editHistory } = message;
|
const { editHistory } = message;
|
||||||
if (editHistory == null) {
|
if (editHistory == null) {
|
||||||
|
@ -2818,11 +2927,13 @@ export class BackupExportStream extends Readable {
|
||||||
await this.#toDirectStoryReplyMessage({
|
await this.#toDirectStoryReplyMessage({
|
||||||
message: history,
|
message: history,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result.standardMessage = await this.#toStandardMessage({
|
result.standardMessage = await this.#toStandardMessage({
|
||||||
message: history,
|
message: history,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
isLocalBackup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -244,18 +244,22 @@ export class BackupImportStream extends Writable {
|
||||||
#pendingGroupAvatars = new Map<string, string>();
|
#pendingGroupAvatars = new Map<string, string>();
|
||||||
#frameErrorCount: number = 0;
|
#frameErrorCount: number = 0;
|
||||||
|
|
||||||
private constructor(private readonly backupType: BackupType) {
|
private constructor(
|
||||||
|
private readonly backupType: BackupType,
|
||||||
|
private readonly localBackupSnapshotDir: string | undefined
|
||||||
|
) {
|
||||||
super({ objectMode: true });
|
super({ objectMode: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async create(
|
public static async create(
|
||||||
backupType = BackupType.Ciphertext
|
backupType = BackupType.Ciphertext,
|
||||||
|
localBackupSnapshotDir: string | undefined = undefined
|
||||||
): Promise<BackupImportStream> {
|
): Promise<BackupImportStream> {
|
||||||
await AttachmentDownloadManager.stop();
|
await AttachmentDownloadManager.stop();
|
||||||
await DataWriter.removeAllBackupAttachmentDownloadJobs();
|
await DataWriter.removeAllBackupAttachmentDownloadJobs();
|
||||||
await resetBackupMediaDownloadProgress();
|
await resetBackupMediaDownloadProgress();
|
||||||
|
|
||||||
return new BackupImportStream(backupType);
|
return new BackupImportStream(backupType, localBackupSnapshotDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async _write(
|
override async _write(
|
||||||
|
@ -1854,11 +1858,19 @@ export class BackupImportStream extends Writable {
|
||||||
bodyRanges: this.#fromBodyRanges(data.text),
|
bodyRanges: this.#fromBodyRanges(data.text),
|
||||||
})),
|
})),
|
||||||
bodyAttachment: data.longText
|
bodyAttachment: data.longText
|
||||||
? convertFilePointerToAttachment(data.longText)
|
? convertFilePointerToAttachment(
|
||||||
|
data.longText,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
attachments: data.attachments?.length
|
attachments: data.attachments?.length
|
||||||
? data.attachments
|
? data.attachments
|
||||||
.map(convertBackupMessageAttachmentToAttachment)
|
.map(attachment =>
|
||||||
|
convertBackupMessageAttachmentToAttachment(
|
||||||
|
attachment,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
|
)
|
||||||
.filter(isNotNil)
|
.filter(isNotNil)
|
||||||
: undefined,
|
: undefined,
|
||||||
preview: data.linkPreview?.length
|
preview: data.linkPreview?.length
|
||||||
|
@ -1902,7 +1914,10 @@ export class BackupImportStream extends Writable {
|
||||||
description: dropNull(preview.description),
|
description: dropNull(preview.description),
|
||||||
date: getCheckedTimestampOrUndefinedFromLong(preview.date),
|
date: getCheckedTimestampOrUndefinedFromLong(preview.date),
|
||||||
image: preview.image
|
image: preview.image
|
||||||
? convertFilePointerToAttachment(preview.image)
|
? convertFilePointerToAttachment(
|
||||||
|
preview.image,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
@ -1917,7 +1932,10 @@ export class BackupImportStream extends Writable {
|
||||||
...(attachment
|
...(attachment
|
||||||
? {
|
? {
|
||||||
attachments: [
|
attachments: [
|
||||||
convertBackupMessageAttachmentToAttachment(attachment),
|
convertBackupMessageAttachmentToAttachment(
|
||||||
|
attachment,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
),
|
||||||
].filter(isNotNil),
|
].filter(isNotNil),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
@ -1948,7 +1966,10 @@ export class BackupImportStream extends Writable {
|
||||||
result.body = textReply.text?.body ?? undefined;
|
result.body = textReply.text?.body ?? undefined;
|
||||||
result.bodyRanges = this.#fromBodyRanges(textReply.text);
|
result.bodyRanges = this.#fromBodyRanges(textReply.text);
|
||||||
result.bodyAttachment = textReply.longText
|
result.bodyAttachment = textReply.longText
|
||||||
? convertFilePointerToAttachment(textReply.longText)
|
? convertFilePointerToAttachment(
|
||||||
|
textReply.longText,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
} else if (emoji) {
|
} else if (emoji) {
|
||||||
result.storyReaction = {
|
result.storyReaction = {
|
||||||
|
@ -1978,7 +1999,10 @@ export class BackupImportStream extends Writable {
|
||||||
body: textReply.text?.body ?? undefined,
|
body: textReply.text?.body ?? undefined,
|
||||||
bodyRanges: this.#fromBodyRanges(textReply.text),
|
bodyRanges: this.#fromBodyRanges(textReply.text),
|
||||||
bodyAttachment: textReply.longText
|
bodyAttachment: textReply.longText
|
||||||
? convertFilePointerToAttachment(textReply.longText)
|
? convertFilePointerToAttachment(
|
||||||
|
textReply.longText,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -2101,7 +2125,10 @@ export class BackupImportStream extends Writable {
|
||||||
? stringToMIMEType(contentType)
|
? stringToMIMEType(contentType)
|
||||||
: APPLICATION_OCTET_STREAM,
|
: APPLICATION_OCTET_STREAM,
|
||||||
thumbnail: thumbnail?.pointer
|
thumbnail: thumbnail?.pointer
|
||||||
? convertFilePointerToAttachment(thumbnail.pointer)
|
? convertFilePointerToAttachment(
|
||||||
|
thumbnail.pointer,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
|
@ -2263,7 +2290,10 @@ export class BackupImportStream extends Writable {
|
||||||
organization: organization || undefined,
|
organization: organization || undefined,
|
||||||
avatar: avatar
|
avatar: avatar
|
||||||
? {
|
? {
|
||||||
avatar: convertFilePointerToAttachment(avatar),
|
avatar: convertFilePointerToAttachment(
|
||||||
|
avatar,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
),
|
||||||
isProfile: false,
|
isProfile: false,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
@ -2310,7 +2340,12 @@ export class BackupImportStream extends Writable {
|
||||||
packId: Bytes.toHex(packId),
|
packId: Bytes.toHex(packId),
|
||||||
packKey: Bytes.toBase64(packKey),
|
packKey: Bytes.toBase64(packKey),
|
||||||
stickerId,
|
stickerId,
|
||||||
data: data ? convertFilePointerToAttachment(data) : undefined,
|
data: data
|
||||||
|
? convertFilePointerToAttachment(
|
||||||
|
data,
|
||||||
|
this.#getFilePointerOptions()
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
reactions: this.#fromReactions(chatItem.stickerMessage.reactions),
|
reactions: this.#fromReactions(chatItem.stickerMessage.reactions),
|
||||||
},
|
},
|
||||||
|
@ -3691,6 +3726,14 @@ export class BackupImportStream extends Writable {
|
||||||
autoBubbleColor,
|
autoBubbleColor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#getFilePointerOptions() {
|
||||||
|
if (this.localBackupSnapshotDir != null) {
|
||||||
|
return { localBackupSnapshotDir: this.localBackupSnapshotDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rgbIntToDesktopHSL(intValue: number): {
|
function rgbIntToDesktopHSL(intValue: number): {
|
||||||
|
|
|
@ -5,15 +5,16 @@ import { pipeline } from 'stream/promises';
|
||||||
import { PassThrough } from 'stream';
|
import { PassThrough } from 'stream';
|
||||||
import type { Readable, Writable } from 'stream';
|
import type { Readable, Writable } from 'stream';
|
||||||
import { createReadStream, createWriteStream } from 'fs';
|
import { createReadStream, createWriteStream } from 'fs';
|
||||||
import { unlink, stat } from 'fs/promises';
|
import { mkdir, stat, unlink } from 'fs/promises';
|
||||||
import { ensureFile } from 'fs-extra';
|
import { ensureFile } from 'fs-extra';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { createGzip, createGunzip } from 'zlib';
|
import { createGzip, createGunzip } from 'zlib';
|
||||||
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
||||||
import { noop } from 'lodash';
|
import { isEqual, noop } from 'lodash';
|
||||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
|
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||||
import { throttle } from 'lodash/fp';
|
import { throttle } from 'lodash/fp';
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
import { DataReader, DataWriter } from '../../sql/Client';
|
import { DataReader, DataWriter } from '../../sql/Client';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
|
@ -49,7 +50,11 @@ import { isTestOrMockEnvironment } from '../../environment';
|
||||||
import { runStorageServiceSyncJob } from '../storage';
|
import { runStorageServiceSyncJob } from '../storage';
|
||||||
import { BackupExportStream, type StatsType } from './export';
|
import { BackupExportStream, type StatsType } from './export';
|
||||||
import { BackupImportStream } from './import';
|
import { BackupImportStream } from './import';
|
||||||
import { getKeyMaterial } from './crypto';
|
import {
|
||||||
|
getBackupId,
|
||||||
|
getKeyMaterial,
|
||||||
|
getLocalBackupMetadataKey,
|
||||||
|
} from './crypto';
|
||||||
import { BackupCredentials } from './credentials';
|
import { BackupCredentials } from './credentials';
|
||||||
import { BackupAPI } from './api';
|
import { BackupAPI } from './api';
|
||||||
import { validateBackup, ValidationType } from './validator';
|
import { validateBackup, ValidationType } from './validator';
|
||||||
|
@ -66,6 +71,16 @@ import { MemoryStream } from './util/MemoryStream';
|
||||||
import { ToastType } from '../../types/Toast';
|
import { ToastType } from '../../types/Toast';
|
||||||
import { isAdhoc, isNightly } from '../../util/version';
|
import { isAdhoc, isNightly } from '../../util/version';
|
||||||
import { getMessageQueueTime } from '../../util/getMessageQueueTime';
|
import { getMessageQueueTime } from '../../util/getMessageQueueTime';
|
||||||
|
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled';
|
||||||
|
import type { ValidateLocalBackupStructureResultType } from './util/localBackup';
|
||||||
|
import {
|
||||||
|
writeLocalBackupMetadata,
|
||||||
|
verifyLocalBackupMetadata,
|
||||||
|
writeLocalBackupFilesList,
|
||||||
|
readLocalBackupFilesList,
|
||||||
|
validateLocalBackupStructure,
|
||||||
|
} from './util/localBackup';
|
||||||
|
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager';
|
||||||
|
|
||||||
export { BackupType };
|
export { BackupType };
|
||||||
|
|
||||||
|
@ -94,6 +109,7 @@ type DoDownloadOptionsType = Readonly<{
|
||||||
|
|
||||||
export type ImportOptionsType = Readonly<{
|
export type ImportOptionsType = Readonly<{
|
||||||
backupType?: BackupType;
|
backupType?: BackupType;
|
||||||
|
localBackupSnapshotDir?: string;
|
||||||
ephemeralKey?: Uint8Array;
|
ephemeralKey?: Uint8Array;
|
||||||
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
||||||
}>;
|
}>;
|
||||||
|
@ -104,9 +120,13 @@ export type ExportResultType = Readonly<{
|
||||||
stats: Readonly<StatsType>;
|
stats: Readonly<StatsType>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type LocalBackupExportResultType = ExportResultType & {
|
||||||
|
snapshotDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ValidationResultType = Readonly<
|
export type ValidationResultType = Readonly<
|
||||||
| {
|
| {
|
||||||
result: ExportResultType;
|
result: ExportResultType | LocalBackupExportResultType;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
error: string;
|
error: string;
|
||||||
|
@ -123,6 +143,8 @@ export class BackupsService {
|
||||||
| ExplodePromiseResultType<RetryBackupImportValue>
|
| ExplodePromiseResultType<RetryBackupImportValue>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
#localBackupSnapshotDir: string | undefined;
|
||||||
|
|
||||||
public readonly credentials = new BackupCredentials();
|
public readonly credentials = new BackupCredentials();
|
||||||
public readonly api = new BackupAPI(this.credentials);
|
public readonly api = new BackupAPI(this.credentials);
|
||||||
public readonly throttledFetchCloudBackupStatus = throttle(
|
public readonly throttledFetchCloudBackupStatus = throttle(
|
||||||
|
@ -263,23 +285,7 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async upload(): Promise<void> {
|
public async upload(): Promise<void> {
|
||||||
// Make sure we are up-to-date on storage service
|
await this.#waitForEmptyQueues('backups.upload');
|
||||||
{
|
|
||||||
const { promise: storageService, resolve } = explodePromise<void>();
|
|
||||||
window.Whisper.events.once('storageService:syncComplete', resolve);
|
|
||||||
|
|
||||||
runStorageServiceSyncJob({ reason: 'backups.upload' });
|
|
||||||
await storageService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear message queue
|
|
||||||
await window.waitForEmptyEventQueue();
|
|
||||||
|
|
||||||
// Make sure all batches are flushed
|
|
||||||
await Promise.all([
|
|
||||||
window.waitForAllBatchers(),
|
|
||||||
window.flushAllWaitBatchers(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
||||||
const filePath = join(window.BasePaths.temp, fileName);
|
const filePath = join(window.BasePaths.temp, fileName);
|
||||||
|
@ -290,9 +296,9 @@ export class BackupsService {
|
||||||
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
|
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileSize = await this.exportToDisk(filePath, backupLevel);
|
const { totalBytes } = await this.exportToDisk(filePath, backupLevel);
|
||||||
|
|
||||||
await this.api.upload(filePath, fileSize);
|
await this.api.upload(filePath, totalBytes);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
|
@ -302,6 +308,95 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async exportLocalBackup(
|
||||||
|
backupsBaseDir: string | undefined = undefined,
|
||||||
|
backupLevel: BackupLevel = BackupLevel.Free
|
||||||
|
): Promise<LocalBackupExportResultType> {
|
||||||
|
strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled');
|
||||||
|
|
||||||
|
await this.#waitForEmptyQueues('backups.exportLocalBackup');
|
||||||
|
|
||||||
|
const baseDir =
|
||||||
|
backupsBaseDir ??
|
||||||
|
join(window.SignalContext.getPath('userData'), 'SignalBackups');
|
||||||
|
const snapshotDir = join(baseDir, `signal-backup-${new Date().getTime()}`);
|
||||||
|
await mkdir(snapshotDir, { recursive: true });
|
||||||
|
const mainProtoPath = join(snapshotDir, 'main');
|
||||||
|
|
||||||
|
log.info('exportLocalBackup: starting');
|
||||||
|
|
||||||
|
const exportResult = await this.exportToDisk(
|
||||||
|
mainProtoPath,
|
||||||
|
backupLevel,
|
||||||
|
BackupType.Ciphertext,
|
||||||
|
snapshotDir
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info('exportLocalBackup: writing metadata');
|
||||||
|
const metadataArgs = {
|
||||||
|
snapshotDir,
|
||||||
|
backupId: getBackupId(),
|
||||||
|
metadataKey: getLocalBackupMetadataKey(),
|
||||||
|
};
|
||||||
|
await writeLocalBackupMetadata(metadataArgs);
|
||||||
|
await verifyLocalBackupMetadata(metadataArgs);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'exportLocalBackup: waiting for AttachmentLocalBackupManager to finish'
|
||||||
|
);
|
||||||
|
await AttachmentLocalBackupManager.waitForIdle();
|
||||||
|
|
||||||
|
log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`);
|
||||||
|
return { ...exportResult, snapshotDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stageLocalBackupForImport(
|
||||||
|
snapshotDir: string
|
||||||
|
): Promise<ValidateLocalBackupStructureResultType> {
|
||||||
|
const result = await validateLocalBackupStructure(snapshotDir);
|
||||||
|
const { success, error } = result;
|
||||||
|
if (success) {
|
||||||
|
this.#localBackupSnapshotDir = snapshotDir;
|
||||||
|
log.info(
|
||||||
|
`stageLocalBackupForImport: Staged ${snapshotDir} for import. Please link to perform import.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.#localBackupSnapshotDir = undefined;
|
||||||
|
log.info(
|
||||||
|
`stageLocalBackupForImport: Invalid snapshot ${snapshotDir}. Error: ${error}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLocalBackupStaged(): boolean {
|
||||||
|
return Boolean(this.#localBackupSnapshotDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async importLocalBackup(): Promise<void> {
|
||||||
|
strictAssert(
|
||||||
|
this.#localBackupSnapshotDir,
|
||||||
|
'importLocalBackup: Staged backup is required, use stageLocalBackupForImport()'
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info(`importLocalBackup: Importing ${this.#localBackupSnapshotDir}`);
|
||||||
|
|
||||||
|
const backupFile = join(this.#localBackupSnapshotDir, 'main');
|
||||||
|
await this.importFromDisk(backupFile, {
|
||||||
|
localBackupSnapshotDir: this.#localBackupSnapshotDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyLocalBackupMetadata({
|
||||||
|
snapshotDir: this.#localBackupSnapshotDir,
|
||||||
|
backupId: getBackupId(),
|
||||||
|
metadataKey: getLocalBackupMetadataKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#localBackupSnapshotDir = undefined;
|
||||||
|
|
||||||
|
log.info('importLocalBackup: Done');
|
||||||
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportBackupData(
|
public async exportBackupData(
|
||||||
backupLevel: BackupLevel = BackupLevel.Free,
|
backupLevel: BackupLevel = BackupLevel.Free,
|
||||||
|
@ -319,29 +414,63 @@ export class BackupsService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
|
||||||
public async exportToDisk(
|
public async exportToDisk(
|
||||||
path: string,
|
path: string,
|
||||||
backupLevel: BackupLevel = BackupLevel.Free,
|
backupLevel: BackupLevel = BackupLevel.Free,
|
||||||
backupType = BackupType.Ciphertext
|
backupType = BackupType.Ciphertext,
|
||||||
): Promise<number> {
|
localBackupSnapshotDir: string | undefined = undefined
|
||||||
const { totalBytes } = await this.#exportBackup(
|
): Promise<ExportResultType> {
|
||||||
|
const exportResult = await this.#exportBackup(
|
||||||
createWriteStream(path),
|
createWriteStream(path),
|
||||||
backupLevel,
|
backupLevel,
|
||||||
backupType
|
backupType,
|
||||||
|
localBackupSnapshotDir
|
||||||
);
|
);
|
||||||
|
|
||||||
if (backupType === BackupType.Ciphertext) {
|
if (backupType === BackupType.Ciphertext) {
|
||||||
await validateBackup(
|
await validateBackup(
|
||||||
() => new FileStream(path),
|
() => new FileStream(path),
|
||||||
totalBytes,
|
exportResult.totalBytes,
|
||||||
isTestOrMockEnvironment()
|
isTestOrMockEnvironment()
|
||||||
? ValidationType.Internal
|
? ValidationType.Internal
|
||||||
: ValidationType.Export
|
: ValidationType.Export
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalBytes;
|
return exportResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async _internalExportLocalBackup(
|
||||||
|
backupLevel: BackupLevel = BackupLevel.Free
|
||||||
|
): Promise<ValidationResultType> {
|
||||||
|
try {
|
||||||
|
const { canceled, dirPath: backupsBaseDir } = await ipcRenderer.invoke(
|
||||||
|
'show-open-folder-dialog'
|
||||||
|
);
|
||||||
|
if (canceled || !backupsBaseDir) {
|
||||||
|
return { error: 'Backups directory not selected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.exportLocalBackup(backupsBaseDir, backupLevel);
|
||||||
|
return { result };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: Errors.toLogFormat(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async _internalStageLocalBackupForImport(): Promise<ValidateLocalBackupStructureResultType> {
|
||||||
|
const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
|
||||||
|
'show-open-folder-dialog'
|
||||||
|
);
|
||||||
|
if (canceled || !snapshotDir) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'File dialog canceled',
|
||||||
|
snapshotDir: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.stageLocalBackupForImport(snapshotDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
|
@ -417,6 +546,7 @@ export class BackupsService {
|
||||||
backupType = BackupType.Ciphertext,
|
backupType = BackupType.Ciphertext,
|
||||||
ephemeralKey,
|
ephemeralKey,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
localBackupSnapshotDir = undefined,
|
||||||
}: ImportOptionsType = {}
|
}: ImportOptionsType = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
strictAssert(!this.#isRunning, 'BackupService is already running');
|
strictAssert(!this.#isRunning, 'BackupService is already running');
|
||||||
|
@ -438,7 +568,10 @@ export class BackupsService {
|
||||||
|
|
||||||
window.ConversationController.setReadOnly(true);
|
window.ConversationController.setReadOnly(true);
|
||||||
|
|
||||||
const importStream = await BackupImportStream.create(backupType);
|
const importStream = await BackupImportStream.create(
|
||||||
|
backupType,
|
||||||
|
localBackupSnapshotDir
|
||||||
|
);
|
||||||
if (backupType === BackupType.Ciphertext) {
|
if (backupType === BackupType.Ciphertext) {
|
||||||
const { aesKey, macKey } = getKeyMaterial(
|
const { aesKey, macKey } = getKeyMaterial(
|
||||||
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
|
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
|
||||||
|
@ -761,7 +894,8 @@ export class BackupsService {
|
||||||
async #exportBackup(
|
async #exportBackup(
|
||||||
sink: Writable,
|
sink: Writable,
|
||||||
backupLevel: BackupLevel = BackupLevel.Free,
|
backupLevel: BackupLevel = BackupLevel.Free,
|
||||||
backupType = BackupType.Ciphertext
|
backupType = BackupType.Ciphertext,
|
||||||
|
localBackupSnapshotDir: string | undefined = undefined
|
||||||
): Promise<ExportResultType> {
|
): Promise<ExportResultType> {
|
||||||
strictAssert(!this.#isRunning, 'BackupService is already running');
|
strictAssert(!this.#isRunning, 'BackupService is already running');
|
||||||
|
|
||||||
|
@ -786,7 +920,7 @@ export class BackupsService {
|
||||||
const { aesKey, macKey } = getKeyMaterial();
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
const recordStream = new BackupExportStream(backupType);
|
const recordStream = new BackupExportStream(backupType);
|
||||||
|
|
||||||
recordStream.run(backupLevel);
|
recordStream.run(backupLevel, localBackupSnapshotDir);
|
||||||
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
@ -817,6 +951,21 @@ export class BackupsService {
|
||||||
throw missingCaseError(backupType);
|
throw missingCaseError(backupType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localBackupSnapshotDir) {
|
||||||
|
log.info('exportBackup: writing local backup files list');
|
||||||
|
const filesWritten = await writeLocalBackupFilesList({
|
||||||
|
snapshotDir: localBackupSnapshotDir,
|
||||||
|
mediaNamesIterator: recordStream.getMediaNamesIterator(),
|
||||||
|
});
|
||||||
|
const filesRead = await readLocalBackupFilesList(
|
||||||
|
localBackupSnapshotDir
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
isEqual(filesWritten, filesRead),
|
||||||
|
'exportBackup: Local backup files proto must match files written'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
return { totalBytes, stats: recordStream.getStats(), duration };
|
return { totalBytes, stats: recordStream.getStats(), duration };
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -866,6 +1015,28 @@ export class BackupsService {
|
||||||
window.reduxActions.installer.startInstaller();
|
window.reduxActions.installer.startInstaller();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #waitForEmptyQueues(
|
||||||
|
reason: 'backups.upload' | 'backups.exportLocalBackup'
|
||||||
|
) {
|
||||||
|
// Make sure we are up-to-date on storage service
|
||||||
|
{
|
||||||
|
const { promise: storageService, resolve } = explodePromise<void>();
|
||||||
|
window.Whisper.events.once('storageService:syncComplete', resolve);
|
||||||
|
|
||||||
|
runStorageServiceSyncJob({ reason });
|
||||||
|
await storageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear message queue
|
||||||
|
await window.waitForEmptyEventQueue();
|
||||||
|
|
||||||
|
// Make sure all batches are flushed
|
||||||
|
await Promise.all([
|
||||||
|
window.waitForAllBatchers(),
|
||||||
|
window.flushAllWaitBatchers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public isImportRunning(): boolean {
|
public isImportRunning(): boolean {
|
||||||
return this.#isRunning === 'import';
|
return this.#isRunning === 'import';
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,11 +40,17 @@ import { bytesToUuid } from '../../../util/uuidToBytes';
|
||||||
import { createName } from '../../../util/attachmentPath';
|
import { createName } from '../../../util/attachmentPath';
|
||||||
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
|
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
|
||||||
import type { ReencryptionInfo } from '../../../AttachmentCrypto';
|
import type { ReencryptionInfo } from '../../../AttachmentCrypto';
|
||||||
|
import { getAttachmentLocalBackupPathFromSnapshotDir } from './localBackup';
|
||||||
|
|
||||||
|
type ConvertFilePointerToAttachmentOptions = {
|
||||||
|
// Only for testing
|
||||||
|
_createName: (suffix?: string) => string;
|
||||||
|
localBackupSnapshotDir: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export function convertFilePointerToAttachment(
|
export function convertFilePointerToAttachment(
|
||||||
filePointer: Backups.FilePointer,
|
filePointer: Backups.FilePointer,
|
||||||
// Only for testing
|
options: Partial<ConvertFilePointerToAttachmentOptions> = {}
|
||||||
{ _createName: doCreateName = createName } = {}
|
|
||||||
): AttachmentType {
|
): AttachmentType {
|
||||||
const {
|
const {
|
||||||
contentType,
|
contentType,
|
||||||
|
@ -58,7 +64,9 @@ export function convertFilePointerToAttachment(
|
||||||
attachmentLocator,
|
attachmentLocator,
|
||||||
backupLocator,
|
backupLocator,
|
||||||
invalidAttachmentLocator,
|
invalidAttachmentLocator,
|
||||||
|
localLocator,
|
||||||
} = filePointer;
|
} = filePointer;
|
||||||
|
const doCreateName = options._createName ?? createName;
|
||||||
|
|
||||||
const commonProps: Omit<AttachmentType, 'size'> = {
|
const commonProps: Omit<AttachmentType, 'size'> = {
|
||||||
contentType: contentType
|
contentType: contentType
|
||||||
|
@ -122,6 +130,57 @@ export function convertFilePointerToAttachment(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localLocator) {
|
||||||
|
const {
|
||||||
|
mediaName,
|
||||||
|
localKey,
|
||||||
|
backupCdnNumber,
|
||||||
|
remoteKey: key,
|
||||||
|
remoteDigest: digest,
|
||||||
|
size,
|
||||||
|
transitCdnKey,
|
||||||
|
transitCdnNumber,
|
||||||
|
} = localLocator;
|
||||||
|
|
||||||
|
const { localBackupSnapshotDir } = options;
|
||||||
|
strictAssert(
|
||||||
|
localBackupSnapshotDir,
|
||||||
|
'localBackupSnapshotDir is required for filePointer.localLocator'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaName == null) {
|
||||||
|
log.error(
|
||||||
|
'convertFilePointerToAttachment: filePointer.localLocator missing mediaName!'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...omit(commonProps, 'downloadPath'),
|
||||||
|
error: true,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const localBackupPath = getAttachmentLocalBackupPathFromSnapshotDir(
|
||||||
|
mediaName,
|
||||||
|
localBackupSnapshotDir
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonProps,
|
||||||
|
cdnKey: transitCdnKey ?? undefined,
|
||||||
|
cdnNumber: transitCdnNumber ?? undefined,
|
||||||
|
key: key?.length ? Bytes.toBase64(key) : undefined,
|
||||||
|
digest: digest?.length ? Bytes.toBase64(digest) : undefined,
|
||||||
|
size: size ?? 0,
|
||||||
|
localBackupPath,
|
||||||
|
localKey: localKey?.length ? Bytes.toBase64(localKey) : undefined,
|
||||||
|
backupLocator: backupCdnNumber
|
||||||
|
? {
|
||||||
|
mediaName,
|
||||||
|
cdnNumber: backupCdnNumber,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!invalidAttachmentLocator) {
|
if (!invalidAttachmentLocator) {
|
||||||
log.error('convertFilePointerToAttachment: filePointer had no locator');
|
log.error('convertFilePointerToAttachment: filePointer had no locator');
|
||||||
}
|
}
|
||||||
|
@ -134,7 +193,8 @@ export function convertFilePointerToAttachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertBackupMessageAttachmentToAttachment(
|
export function convertBackupMessageAttachmentToAttachment(
|
||||||
messageAttachment: Backups.IMessageAttachment
|
messageAttachment: Backups.IMessageAttachment,
|
||||||
|
options: Partial<ConvertFilePointerToAttachmentOptions> = {}
|
||||||
): AttachmentType | null {
|
): AttachmentType | null {
|
||||||
const { clientUuid } = messageAttachment;
|
const { clientUuid } = messageAttachment;
|
||||||
|
|
||||||
|
@ -142,7 +202,7 @@ export function convertBackupMessageAttachmentToAttachment(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
...convertFilePointerToAttachment(messageAttachment.pointer),
|
...convertFilePointerToAttachment(messageAttachment.pointer, options),
|
||||||
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
|
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -372,6 +432,99 @@ export async function getFilePointerForAttachment({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Given a remote backup FilePointer, return a FilePointer referencing a local backup
|
||||||
|
export async function getLocalBackupFilePointerForAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
getBackupCdnInfo,
|
||||||
|
}: {
|
||||||
|
attachment: Readonly<AttachmentType>;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
getBackupCdnInfo: GetBackupCdnInfoType;
|
||||||
|
}): Promise<{
|
||||||
|
filePointer: Backups.FilePointer;
|
||||||
|
updatedAttachment?: AttachmentType;
|
||||||
|
}> {
|
||||||
|
const { filePointer: remoteFilePointer, updatedAttachment } =
|
||||||
|
await getFilePointerForAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
getBackupCdnInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attachment.localKey == null) {
|
||||||
|
return { filePointer: remoteFilePointer, updatedAttachment };
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
attachment.localKey != null,
|
||||||
|
'getLocalBackupFilePointerForAttachment: attachment must have localKey'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteFilePointer.backupLocator) {
|
||||||
|
const { backupLocator } = remoteFilePointer;
|
||||||
|
const { mediaName } = backupLocator;
|
||||||
|
strictAssert(
|
||||||
|
mediaName,
|
||||||
|
'getLocalBackupFilePointerForAttachment: BackupLocator must have mediaName'
|
||||||
|
);
|
||||||
|
|
||||||
|
const localLocator = new Backups.FilePointer.LocalLocator({
|
||||||
|
mediaName,
|
||||||
|
localKey: Bytes.fromBase64(attachment.localKey),
|
||||||
|
remoteKey: backupLocator.key,
|
||||||
|
remoteDigest: backupLocator.digest,
|
||||||
|
size: backupLocator.size,
|
||||||
|
backupCdnNumber: backupLocator.cdnNumber,
|
||||||
|
transitCdnKey: backupLocator.transitCdnKey,
|
||||||
|
transitCdnNumber: backupLocator.transitCdnNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePointer: {
|
||||||
|
...omit(remoteFilePointer, 'backupLocator'),
|
||||||
|
localLocator,
|
||||||
|
},
|
||||||
|
updatedAttachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteFilePointer.attachmentLocator) {
|
||||||
|
const { attachmentLocator } = remoteFilePointer;
|
||||||
|
const { digest } = attachmentLocator;
|
||||||
|
strictAssert(
|
||||||
|
digest,
|
||||||
|
'getLocalBackupFilePointerForAttachment: AttachmentLocator must have digest'
|
||||||
|
);
|
||||||
|
const mediaName = getMediaNameFromDigest(Bytes.toBase64(digest));
|
||||||
|
strictAssert(
|
||||||
|
mediaName,
|
||||||
|
'getLocalBackupFilePointerForAttachment: mediaName must be derivable from AttachmentLocator'
|
||||||
|
);
|
||||||
|
|
||||||
|
const localLocator = new Backups.FilePointer.LocalLocator({
|
||||||
|
mediaName,
|
||||||
|
localKey: Bytes.fromBase64(attachment.localKey),
|
||||||
|
remoteKey: attachmentLocator.key,
|
||||||
|
remoteDigest: attachmentLocator.digest,
|
||||||
|
size: attachmentLocator.size,
|
||||||
|
backupCdnNumber: undefined,
|
||||||
|
transitCdnKey: attachmentLocator.cdnKey,
|
||||||
|
transitCdnNumber: attachmentLocator.cdnNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePointer: {
|
||||||
|
...omit(remoteFilePointer, 'attachmentLocator'),
|
||||||
|
localLocator,
|
||||||
|
},
|
||||||
|
updatedAttachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePointer: remoteFilePointer, updatedAttachment };
|
||||||
|
}
|
||||||
|
|
||||||
function getAttachmentLocator(
|
function getAttachmentLocator(
|
||||||
attachment: AttachmentDownloadableFromTransitTier
|
attachment: AttachmentDownloadableFromTransitTier
|
||||||
) {
|
) {
|
||||||
|
@ -419,21 +572,33 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
|
||||||
getBackupCdnInfo: GetBackupCdnInfoType;
|
getBackupCdnInfo: GetBackupCdnInfoType;
|
||||||
messageReceivedAt: number;
|
messageReceivedAt: number;
|
||||||
}): Promise<CoreAttachmentBackupJobType | null> {
|
}): Promise<CoreAttachmentBackupJobType | null> {
|
||||||
if (!filePointer.backupLocator) {
|
let locator:
|
||||||
|
| Backups.FilePointer.IBackupLocator
|
||||||
|
| Backups.FilePointer.ILocalLocator;
|
||||||
|
if (filePointer.backupLocator) {
|
||||||
|
locator = filePointer.backupLocator;
|
||||||
|
} else if (filePointer.localLocator) {
|
||||||
|
locator = filePointer.localLocator;
|
||||||
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mediaName } = filePointer.backupLocator;
|
const { mediaName } = locator;
|
||||||
strictAssert(mediaName, 'mediaName must exist');
|
strictAssert(mediaName, 'mediaName must exist');
|
||||||
|
|
||||||
const { isInBackupTier } = await getBackupCdnInfo(
|
if (filePointer.backupLocator) {
|
||||||
getMediaIdFromMediaName(mediaName).string
|
const { isInBackupTier } = await getBackupCdnInfo(
|
||||||
);
|
getMediaIdFromMediaName(mediaName).string
|
||||||
|
);
|
||||||
if (isInBackupTier) {
|
if (isInBackupTier) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: For local backups we don't want to double back up the same file, so
|
||||||
|
// we could check for the same file here and if it's found then return early.
|
||||||
|
// Also ok to skip downstream when the job runs.
|
||||||
|
|
||||||
strictAssert(
|
strictAssert(
|
||||||
isAttachmentLocallySaved(attachment),
|
isAttachmentLocallySaved(attachment),
|
||||||
'Attachment must be saved locally for it to be backed up'
|
'Attachment must be saved locally for it to be backed up'
|
||||||
|
@ -455,19 +620,38 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
|
||||||
encryptionInfo = attachment.reencryptionInfo;
|
encryptionInfo = attachment.reencryptionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
strictAssert(
|
if (filePointer.backupLocator) {
|
||||||
filePointer.backupLocator.digest,
|
strictAssert(
|
||||||
'digest must exist on backupLocator'
|
filePointer.backupLocator.digest,
|
||||||
);
|
'digest must exist on backupLocator'
|
||||||
strictAssert(
|
);
|
||||||
encryptionInfo.digest === Bytes.toBase64(filePointer.backupLocator.digest),
|
strictAssert(
|
||||||
'digest on job and backupLocator must match'
|
encryptionInfo.digest ===
|
||||||
);
|
Bytes.toBase64(filePointer.backupLocator.digest),
|
||||||
|
'digest on job and backupLocator must match'
|
||||||
|
);
|
||||||
|
} else if (filePointer.localLocator) {
|
||||||
|
strictAssert(
|
||||||
|
filePointer.localLocator.remoteDigest,
|
||||||
|
'digest must exist on backupLocator'
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
encryptionInfo.digest ===
|
||||||
|
Bytes.toBase64(filePointer.localLocator.remoteDigest),
|
||||||
|
'digest on job and localLocator must match'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { path, contentType, size, uploadTimestamp, version, localKey } =
|
const { path, contentType, size, uploadTimestamp, version } = attachment;
|
||||||
attachment;
|
|
||||||
|
|
||||||
const { transitCdnKey, transitCdnNumber } = filePointer.backupLocator;
|
const { transitCdnKey, transitCdnNumber } = locator;
|
||||||
|
|
||||||
|
let localKey: string | undefined;
|
||||||
|
if (filePointer.localLocator && filePointer.localLocator.localKey != null) {
|
||||||
|
localKey = Bytes.toBase64(filePointer.localLocator.localKey);
|
||||||
|
} else {
|
||||||
|
localKey = attachment.localKey;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaName,
|
mediaName,
|
||||||
|
|
296
ts/services/backups/util/localBackup.ts
Normal file
296
ts/services/backups/util/localBackup.ts
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { readFile, stat, writeFile } from 'fs/promises';
|
||||||
|
import { createReadStream, createWriteStream } from 'fs';
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
import * as log from '../../../logging/log';
|
||||||
|
import * as Bytes from '../../../Bytes';
|
||||||
|
import * as Errors from '../../../types/errors';
|
||||||
|
import { Signal } from '../../../protobuf';
|
||||||
|
import protobuf from '../../../protobuf/wrap';
|
||||||
|
import { strictAssert } from '../../../util/assert';
|
||||||
|
import { decryptAesCtr, encryptAesCtr } from '../../../Crypto';
|
||||||
|
import type { LocalBackupMetadataVerificationType } from '../../../types/backups';
|
||||||
|
import {
|
||||||
|
LOCAL_BACKUP_VERSION,
|
||||||
|
LOCAL_BACKUP_BACKUP_ID_IV_LENGTH,
|
||||||
|
} from '../constants';
|
||||||
|
import { explodePromise } from '../../../util/explodePromise';
|
||||||
|
|
||||||
|
const { Reader } = protobuf;
|
||||||
|
|
||||||
|
export function getLocalBackupDirectoryForMediaName({
|
||||||
|
backupsBaseDir,
|
||||||
|
mediaName,
|
||||||
|
}: {
|
||||||
|
backupsBaseDir: string;
|
||||||
|
mediaName: string;
|
||||||
|
}): string {
|
||||||
|
if (mediaName.length < 2) {
|
||||||
|
throw new Error('Invalid mediaName input');
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(backupsBaseDir, 'files', mediaName.substring(0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBackupPathForMediaName({
|
||||||
|
backupsBaseDir,
|
||||||
|
mediaName,
|
||||||
|
}: {
|
||||||
|
backupsBaseDir: string;
|
||||||
|
mediaName: string;
|
||||||
|
}): string {
|
||||||
|
return join(
|
||||||
|
getLocalBackupDirectoryForMediaName({ backupsBaseDir, mediaName }),
|
||||||
|
mediaName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a target local backup import e.g. /etc/SignalBackups/signal-backup-1743119037066
|
||||||
|
* and an attachment, return the attachment's file path within the local backup
|
||||||
|
* e.g. /etc/SignalBackups/files/a1/[a1bcdef...]
|
||||||
|
*
|
||||||
|
* @param {string} snapshotDir - Timestamped local backup directory
|
||||||
|
*/
|
||||||
|
export function getAttachmentLocalBackupPathFromSnapshotDir(
|
||||||
|
mediaName: string,
|
||||||
|
snapshotDir: string
|
||||||
|
): string {
|
||||||
|
return join(
|
||||||
|
dirname(snapshotDir),
|
||||||
|
'files',
|
||||||
|
mediaName.substring(0, 2),
|
||||||
|
mediaName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeLocalBackupMetadata({
|
||||||
|
snapshotDir,
|
||||||
|
backupId,
|
||||||
|
metadataKey,
|
||||||
|
}: LocalBackupMetadataVerificationType): Promise<void> {
|
||||||
|
const iv = randomBytes(LOCAL_BACKUP_BACKUP_ID_IV_LENGTH);
|
||||||
|
const encryptedId = encryptAesCtr(metadataKey, backupId, iv);
|
||||||
|
|
||||||
|
const metadataSerialized = Signal.backup.local.Metadata.encode({
|
||||||
|
backupId: new Signal.backup.local.Metadata.EncryptedBackupId({
|
||||||
|
iv,
|
||||||
|
encryptedId,
|
||||||
|
}),
|
||||||
|
version: LOCAL_BACKUP_VERSION,
|
||||||
|
}).finish();
|
||||||
|
|
||||||
|
const metadataPath = join(snapshotDir, 'metadata');
|
||||||
|
await writeFile(metadataPath, metadataSerialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalBackupMetadata({
|
||||||
|
snapshotDir,
|
||||||
|
backupId,
|
||||||
|
metadataKey,
|
||||||
|
}: LocalBackupMetadataVerificationType): Promise<boolean> {
|
||||||
|
const metadataPath = join(snapshotDir, 'metadata');
|
||||||
|
const metadataSerialized = await readFile(metadataPath);
|
||||||
|
|
||||||
|
const metadata = Signal.backup.local.Metadata.decode(metadataSerialized);
|
||||||
|
strictAssert(
|
||||||
|
metadata.version === LOCAL_BACKUP_VERSION,
|
||||||
|
'verifyLocalBackupMetadata: Local backup version must match'
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
metadata.backupId,
|
||||||
|
'verifyLocalBackupMetadata: Must have backupId'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { iv, encryptedId } = metadata.backupId;
|
||||||
|
strictAssert(iv, 'verifyLocalBackupMetadata: Must have backupId.iv');
|
||||||
|
strictAssert(
|
||||||
|
encryptedId,
|
||||||
|
'verifyLocalBackupMetadata: Must have backupId.encryptedId'
|
||||||
|
);
|
||||||
|
|
||||||
|
const localBackupBackupId = decryptAesCtr(metadataKey, encryptedId, iv);
|
||||||
|
strictAssert(
|
||||||
|
Bytes.areEqual(backupId, localBackupBackupId),
|
||||||
|
'verifyLocalBackupMetadata: backupId must match the local backup backupId'
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeLocalBackupFilesList({
|
||||||
|
snapshotDir,
|
||||||
|
mediaNamesIterator,
|
||||||
|
}: {
|
||||||
|
snapshotDir: string;
|
||||||
|
mediaNamesIterator: MapIterator<string>;
|
||||||
|
}): Promise<ReadonlyArray<string>> {
|
||||||
|
const { promise, resolve, reject } = explodePromise<ReadonlyArray<string>>();
|
||||||
|
|
||||||
|
const filesListPath = join(snapshotDir, 'files');
|
||||||
|
const writeStream = createWriteStream(filesListPath);
|
||||||
|
writeStream.on('error', error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files: Array<string> = [];
|
||||||
|
for (const mediaName of mediaNamesIterator) {
|
||||||
|
const data = Signal.backup.local.FilesFrame.encodeDelimited({
|
||||||
|
mediaName,
|
||||||
|
}).finish();
|
||||||
|
if (!writeStream.write(data)) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise(resolveStream =>
|
||||||
|
writeStream.once('drain', resolveStream)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
files.push(mediaName);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStream.end(() => {
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalBackupFilesList(
|
||||||
|
snapshotDir: string
|
||||||
|
): Promise<ReadonlyArray<string>> {
|
||||||
|
const filesListPath = join(snapshotDir, 'files');
|
||||||
|
const readStream = createReadStream(filesListPath);
|
||||||
|
const parseFilesTransform = new ParseFilesListTransform();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pipeline(readStream, parseFilesTransform);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
readStream.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
log.error(
|
||||||
|
'readLocalBackupFilesList: Error when closing readStream',
|
||||||
|
Errors.toLogFormat(closeError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
readStream.close();
|
||||||
|
|
||||||
|
return parseFilesTransform.mediaNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParseFilesListTransform extends Transform {
|
||||||
|
public mediaNames: Array<string> = [];
|
||||||
|
|
||||||
|
public activeFile: Signal.backup.local.FilesFrame | undefined;
|
||||||
|
#unused: Uint8Array | undefined;
|
||||||
|
|
||||||
|
override async _transform(
|
||||||
|
chunk: Buffer | undefined,
|
||||||
|
_encoding: string,
|
||||||
|
done: (error?: Error) => void
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chunk || chunk.byteLength === 0) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = chunk;
|
||||||
|
if (this.#unused) {
|
||||||
|
data = Buffer.concat([this.#unused, data]);
|
||||||
|
this.#unused = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = Reader.create(data);
|
||||||
|
while (reader.pos < reader.len) {
|
||||||
|
const startPos = reader.pos;
|
||||||
|
|
||||||
|
if (!this.activeFile) {
|
||||||
|
try {
|
||||||
|
this.activeFile =
|
||||||
|
Signal.backup.local.FilesFrame.decodeDelimited(reader);
|
||||||
|
} catch (err) {
|
||||||
|
// We get a RangeError if there wasn't enough data to read the next record.
|
||||||
|
if (err instanceof RangeError) {
|
||||||
|
// Note: A failed decodeDelimited() does in fact update reader.pos, so we
|
||||||
|
// must reset to startPos
|
||||||
|
this.#unused = data.subarray(startPos);
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something deeper has gone wrong; the proto is malformed or something
|
||||||
|
done(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.activeFile) {
|
||||||
|
done(
|
||||||
|
new Error(
|
||||||
|
'ParseFilesListTransform: No active file after successful decode!'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeFile.mediaName) {
|
||||||
|
this.mediaNames.push(this.activeFile.mediaName);
|
||||||
|
} else {
|
||||||
|
log.warn(
|
||||||
|
'ParseFilesListTransform: Active file had empty mediaName, ignoring'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeFile = undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
done(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateLocalBackupStructureResultType =
|
||||||
|
| { success: true; error: undefined; snapshotDir: string | undefined }
|
||||||
|
| { success: false; error: string; snapshotDir: string | undefined };
|
||||||
|
|
||||||
|
export async function validateLocalBackupStructure(
|
||||||
|
snapshotDir: string
|
||||||
|
): Promise<ValidateLocalBackupStructureResultType> {
|
||||||
|
try {
|
||||||
|
await stat(snapshotDir);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Snapshot directory does not exist',
|
||||||
|
snapshotDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of ['main', 'metadata', 'files']) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await stat(join(snapshotDir, 'main'));
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Snapshot directory does not contain ${file} file`,
|
||||||
|
snapshotDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, error: undefined, snapshotDir };
|
||||||
|
}
|
|
@ -4088,7 +4088,7 @@ const showSaveMultiDialog = (): Promise<{
|
||||||
canceled: boolean;
|
canceled: boolean;
|
||||||
dirPath?: string;
|
dirPath?: string;
|
||||||
}> => {
|
}> => {
|
||||||
return ipcRenderer.invoke('show-save-multi-dialog');
|
return ipcRenderer.invoke('show-open-folder-dialog', { useMainWindow: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SaveAttachmentsActionCreatorType = ReadonlyDeep<
|
export type SaveAttachmentsActionCreatorType = ReadonlyDeep<
|
||||||
|
|
|
@ -50,6 +50,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -86,6 +87,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -123,6 +125,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -161,6 +164,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -210,6 +214,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -260,6 +265,7 @@ describe('utils/downloadAttachment', () => {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
|
downloadAttachmentFromLocalBackup: stubDownload,
|
||||||
downloadAttachmentFromServer: stubDownload,
|
downloadAttachmentFromServer: stubDownload,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import os from 'os';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import createDebug from 'debug';
|
import createDebug from 'debug';
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
import { Proto, StorageState } from '@signalapp/mock-server';
|
import { Proto, StorageState } from '@signalapp/mock-server';
|
||||||
|
import { assert } from 'chai';
|
||||||
import { expect } from 'playwright/test';
|
import { expect } from 'playwright/test';
|
||||||
|
|
||||||
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
|
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
|
||||||
|
@ -17,7 +20,7 @@ import { IMAGE_JPEG } from '../../types/MIME';
|
||||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||||
import * as durations from '../../util/durations';
|
import * as durations from '../../util/durations';
|
||||||
import type { App } from '../playwright';
|
import type { App } from '../playwright';
|
||||||
import { Bootstrap } from '../bootstrap';
|
import { Bootstrap, type LinkOptionsType } from '../bootstrap';
|
||||||
import {
|
import {
|
||||||
getMessageInTimelineByTimestamp,
|
getMessageInTimelineByTimestamp,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
|
@ -60,7 +63,11 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
await bootstrap.teardown();
|
await bootstrap.teardown();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports and imports regular backup', async function () {
|
async function generateTestDataThenRestoreBackup(
|
||||||
|
thisVal: Mocha.Context,
|
||||||
|
exportBackupFn: () => void,
|
||||||
|
getBootstrapLinkParams: () => LinkOptionsType
|
||||||
|
) {
|
||||||
let state = StorageState.getEmpty();
|
let state = StorageState.getEmpty();
|
||||||
|
|
||||||
const { phone, contacts } = bootstrap;
|
const { phone, contacts } = bootstrap;
|
||||||
|
@ -237,7 +244,7 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
.waitFor();
|
.waitFor();
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.uploadBackup();
|
await exportBackupFn();
|
||||||
|
|
||||||
const comparator = await bootstrap.createScreenshotComparator(
|
const comparator = await bootstrap.createScreenshotComparator(
|
||||||
app,
|
app,
|
||||||
|
@ -288,7 +295,7 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
|
|
||||||
await snapshot('story privacy');
|
await snapshot('story privacy');
|
||||||
},
|
},
|
||||||
this.test
|
thisVal.test
|
||||||
);
|
);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
|
@ -296,7 +303,7 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
// Restart
|
// Restart
|
||||||
await bootstrap.eraseStorage();
|
await bootstrap.eraseStorage();
|
||||||
await server.removeAllCDNAttachments();
|
await server.removeAllCDNAttachments();
|
||||||
app = await bootstrap.link();
|
app = await bootstrap.link(getBootstrapLinkParams());
|
||||||
await app.waitForBackupImportComplete();
|
await app.waitForBackupImportComplete();
|
||||||
|
|
||||||
// Make sure that contact sync happens after backup import, otherwise the
|
// Make sure that contact sync happens after backup import, otherwise the
|
||||||
|
@ -312,6 +319,35 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await comparator(app);
|
await comparator(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('exports and imports local backup', async function () {
|
||||||
|
let snapshotDir: string;
|
||||||
|
|
||||||
|
await generateTestDataThenRestoreBackup(
|
||||||
|
this,
|
||||||
|
async () => {
|
||||||
|
const backupsBaseDir = await fs.mkdtemp(
|
||||||
|
join(os.tmpdir(), 'SignalBackups')
|
||||||
|
);
|
||||||
|
snapshotDir = await app.exportLocalBackup(backupsBaseDir);
|
||||||
|
assert.exists(
|
||||||
|
snapshotDir,
|
||||||
|
'Local backup export should return backup dir'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => ({ localBackup: snapshotDir })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports and imports regular backup', async function () {
|
||||||
|
await generateTestDataThenRestoreBackup(
|
||||||
|
this,
|
||||||
|
async () => {
|
||||||
|
await app.uploadBackup();
|
||||||
|
},
|
||||||
|
() => ({})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports ephemeral backup', async function () {
|
it('imports ephemeral backup', async function () {
|
||||||
|
|
|
@ -132,6 +132,7 @@ export type EphemeralBackupType = Readonly<
|
||||||
export type LinkOptionsType = Readonly<{
|
export type LinkOptionsType = Readonly<{
|
||||||
extraConfig?: Partial<RendererConfigType>;
|
extraConfig?: Partial<RendererConfigType>;
|
||||||
ephemeralBackup?: EphemeralBackupType;
|
ephemeralBackup?: EphemeralBackupType;
|
||||||
|
localBackup?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type BootstrapInternalOptions = BootstrapOptions &
|
type BootstrapInternalOptions = BootstrapOptions &
|
||||||
|
@ -376,6 +377,7 @@ export class Bootstrap {
|
||||||
public async link({
|
public async link({
|
||||||
extraConfig,
|
extraConfig,
|
||||||
ephemeralBackup,
|
ephemeralBackup,
|
||||||
|
localBackup,
|
||||||
}: LinkOptionsType = {}): Promise<App> {
|
}: LinkOptionsType = {}): Promise<App> {
|
||||||
debug('linking');
|
debug('linking');
|
||||||
|
|
||||||
|
@ -383,6 +385,10 @@ export class Bootstrap {
|
||||||
|
|
||||||
const window = await app.getWindow();
|
const window = await app.getWindow();
|
||||||
|
|
||||||
|
if (localBackup != null) {
|
||||||
|
await app.stageLocalBackupForImport(localBackup);
|
||||||
|
}
|
||||||
|
|
||||||
debug('looking for QR code or relink button');
|
debug('looking for QR code or relink button');
|
||||||
const qrCode = window.locator(
|
const qrCode = window.locator(
|
||||||
'.module-InstallScreenQrCodeNotScannedStep__qr-code__code'
|
'.module-InstallScreenQrCodeNotScannedStep__qr-code__code'
|
||||||
|
|
|
@ -207,6 +207,20 @@ export class App extends EventEmitter {
|
||||||
return window.evaluate(`window.SignalCI.getMessagesBySentAt(${timestamp})`);
|
return window.evaluate(`window.SignalCI.getMessagesBySentAt(${timestamp})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async exportLocalBackup(backupsBaseDir: string): Promise<string> {
|
||||||
|
const window = await this.getWindow();
|
||||||
|
return window.evaluate(
|
||||||
|
`window.SignalCI.exportLocalBackup('${backupsBaseDir}')`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stageLocalBackupForImport(snapshotDir: string): Promise<void> {
|
||||||
|
const window = await this.getWindow();
|
||||||
|
return window.evaluate(
|
||||||
|
`window.SignalCI.stageLocalBackupForImport('${snapshotDir}')`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async uploadBackup(): Promise<void> {
|
public async uploadBackup(): Promise<void> {
|
||||||
const window = await this.getWindow();
|
const window = await this.getWindow();
|
||||||
await window.evaluate('window.SignalCI.uploadBackup()');
|
await window.evaluate('window.SignalCI.uploadBackup()');
|
||||||
|
|
|
@ -1209,6 +1209,8 @@ export default class AccountManager extends EventTarget {
|
||||||
}
|
}
|
||||||
if (accountEntropyPool) {
|
if (accountEntropyPool) {
|
||||||
await storage.put('accountEntropyPool', accountEntropyPool);
|
await storage.put('accountEntropyPool', accountEntropyPool);
|
||||||
|
} else {
|
||||||
|
log.warn('createAccount: accountEntropyPool was missing!');
|
||||||
}
|
}
|
||||||
let derivedMasterKey = masterKey;
|
let derivedMasterKey = masterKey;
|
||||||
if (derivedMasterKey == null) {
|
if (derivedMasterKey == null) {
|
||||||
|
|
|
@ -55,7 +55,7 @@ export function getCdnKey(attachment: ProcessedAttachment): string {
|
||||||
return cdnKey;
|
return cdnKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackupMediaOuterEncryptionKeyMaterial(
|
export function getBackupMediaOuterEncryptionKeyMaterial(
|
||||||
attachment: AttachmentType
|
attachment: AttachmentType
|
||||||
): BackupMediaKeyMaterialType {
|
): BackupMediaKeyMaterialType {
|
||||||
const mediaId = getMediaIdForAttachment(attachment);
|
const mediaId = getMediaIdForAttachment(attachment);
|
||||||
|
|
|
@ -109,6 +109,7 @@ export type AttachmentType = {
|
||||||
mediaName: string;
|
mediaName: string;
|
||||||
cdnNumber?: number;
|
cdnNumber?: number;
|
||||||
};
|
};
|
||||||
|
localBackupPath?: string;
|
||||||
|
|
||||||
// See app/attachment_channel.ts
|
// See app/attachment_channel.ts
|
||||||
version?: 1 | 2;
|
version?: 1 | 2;
|
||||||
|
@ -1281,6 +1282,12 @@ export function mightBeOnBackupTier(
|
||||||
return Boolean(attachment.backupLocator?.mediaName);
|
return Boolean(attachment.backupLocator?.mediaName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mightBeInLocalBackup(
|
||||||
|
attachment: Pick<AttachmentType, 'localBackupPath' | 'localKey'>
|
||||||
|
): boolean {
|
||||||
|
return Boolean(attachment.localBackupPath && attachment.localKey);
|
||||||
|
}
|
||||||
|
|
||||||
export function isDownloadableFromTransitTier(
|
export function isDownloadableFromTransitTier(
|
||||||
attachment: AttachmentType
|
attachment: AttachmentType
|
||||||
): attachment is AttachmentDownloadableFromTransitTier {
|
): attachment is AttachmentDownloadableFromTransitTier {
|
||||||
|
|
|
@ -45,6 +45,14 @@ export type ThumbnailAttachmentBackupJobType = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CoreAttachmentLocalBackupJobType =
|
||||||
|
StandardAttachmentBackupJobType & {
|
||||||
|
backupsBaseDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
|
|
@ -61,3 +61,9 @@ export type BackupsSubscriptionType =
|
||||||
cost?: SubscriptionCostType;
|
cost?: SubscriptionCostType;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type LocalBackupMetadataVerificationType = {
|
||||||
|
snapshotDir: string;
|
||||||
|
backupId: Uint8Array;
|
||||||
|
metadataKey: Uint8Array;
|
||||||
|
};
|
||||||
|
|
|
@ -69,7 +69,8 @@ import type {
|
||||||
BackupStatusType,
|
BackupStatusType,
|
||||||
} from '../types/backups';
|
} from '../types/backups';
|
||||||
import { isBackupFeatureEnabled } from './isBackupEnabled';
|
import { isBackupFeatureEnabled } from './isBackupEnabled';
|
||||||
import * as RemoteConfig from '../RemoteConfig';
|
import { isSettingsInternalEnabled } from './isSettingsInternalEnabled';
|
||||||
|
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
|
||||||
|
|
||||||
type SentMediaQualityType = 'standard' | 'high';
|
type SentMediaQualityType = 'standard' | 'high';
|
||||||
type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
|
type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
|
||||||
|
@ -164,6 +165,8 @@ export type IPCEventsCallbacksType = {
|
||||||
unknownSignalLink: () => void;
|
unknownSignalLink: () => void;
|
||||||
getCustomColors: () => Record<string, CustomColorType>;
|
getCustomColors: () => Record<string, CustomColorType>;
|
||||||
syncRequest: () => Promise<void>;
|
syncRequest: () => Promise<void>;
|
||||||
|
exportLocalBackup: () => Promise<BackupValidationResultType>;
|
||||||
|
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
|
||||||
validateBackup: () => Promise<BackupValidationResultType>;
|
validateBackup: () => Promise<BackupValidationResultType>;
|
||||||
setGlobalDefaultConversationColor: (
|
setGlobalDefaultConversationColor: (
|
||||||
color: ConversationColorType,
|
color: ConversationColorType,
|
||||||
|
@ -542,7 +545,7 @@ export function createIPCEvents(
|
||||||
},
|
},
|
||||||
|
|
||||||
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
|
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
|
||||||
isInternalUser: () => RemoteConfig.isEnabled('desktop.internalUser'),
|
isInternalUser: () => isSettingsInternalEnabled(),
|
||||||
syncRequest: async () => {
|
syncRequest: async () => {
|
||||||
const contactSyncComplete = waitForEvent(
|
const contactSyncComplete = waitForEvent(
|
||||||
'contactSync:complete',
|
'contactSync:complete',
|
||||||
|
@ -552,6 +555,9 @@ export function createIPCEvents(
|
||||||
return contactSyncComplete;
|
return contactSyncComplete;
|
||||||
},
|
},
|
||||||
// Only for internal use
|
// Only for internal use
|
||||||
|
exportLocalBackup: () => backupsService._internalExportLocalBackup(),
|
||||||
|
importLocalBackup: () =>
|
||||||
|
backupsService._internalStageLocalBackupForImport(),
|
||||||
validateBackup: () => backupsService._internalValidate(),
|
validateBackup: () => backupsService._internalValidate(),
|
||||||
getLastSyncTime: () => window.storage.get('synced_at'),
|
getLastSyncTime: () => window.storage.get('synced_at'),
|
||||||
setLastSyncTime: value => window.storage.put('synced_at', value),
|
setLastSyncTime: value => window.storage.put('synced_at', value),
|
||||||
|
|
|
@ -7,8 +7,10 @@ import {
|
||||||
AttachmentVariant,
|
AttachmentVariant,
|
||||||
AttachmentPermanentlyUndownloadableError,
|
AttachmentPermanentlyUndownloadableError,
|
||||||
getAttachmentIdForLogging,
|
getAttachmentIdForLogging,
|
||||||
|
mightBeInLocalBackup,
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
|
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
|
||||||
|
import { downloadAttachmentFromLocalBackup as doDownloadAttachmentFromLocalBackup } from './downloadAttachmentFromLocalBackup';
|
||||||
import { MediaTier } from '../types/AttachmentDownload';
|
import { MediaTier } from '../types/AttachmentDownload';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { HTTPError } from '../textsecure/Errors';
|
import { HTTPError } from '../textsecure/Errors';
|
||||||
|
@ -18,7 +20,10 @@ import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto';
|
||||||
export async function downloadAttachment({
|
export async function downloadAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
|
options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
|
||||||
dependencies = { downloadAttachmentFromServer: doDownloadAttachment },
|
dependencies = {
|
||||||
|
downloadAttachmentFromServer: doDownloadAttachment,
|
||||||
|
downloadAttachmentFromLocalBackup: doDownloadAttachmentFromLocalBackup,
|
||||||
|
},
|
||||||
}: {
|
}: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
options: {
|
options: {
|
||||||
|
@ -26,7 +31,10 @@ export async function downloadAttachment({
|
||||||
onSizeUpdate: (totalBytes: number) => void;
|
onSizeUpdate: (totalBytes: number) => void;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
};
|
};
|
||||||
dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment };
|
dependencies?: {
|
||||||
|
downloadAttachmentFromServer: typeof doDownloadAttachment;
|
||||||
|
downloadAttachmentFromLocalBackup: typeof doDownloadAttachmentFromLocalBackup;
|
||||||
|
};
|
||||||
}): Promise<ReencryptedAttachmentV2> {
|
}): Promise<ReencryptedAttachmentV2> {
|
||||||
const attachmentId = getAttachmentIdForLogging(attachment);
|
const attachmentId = getAttachmentIdForLogging(attachment);
|
||||||
const variantForLogging =
|
const variantForLogging =
|
||||||
|
@ -51,6 +59,23 @@ export async function downloadAttachment({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mightBeInLocalBackup(attachment)) {
|
||||||
|
log.info(`${logId}: Downloading attachment from local backup`);
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
await dependencies.downloadAttachmentFromLocalBackup(attachment);
|
||||||
|
onSizeUpdate(attachment.size);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// We also just log this error instead of throwing, since we want to still try to
|
||||||
|
// find it on the backup then transit tiers.
|
||||||
|
log.error(
|
||||||
|
`${logId}: error when downloading from local backup; will try backup and transit tier`,
|
||||||
|
toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mightBeOnBackupTier(migratedAttachment)) {
|
if (mightBeOnBackupTier(migratedAttachment)) {
|
||||||
try {
|
try {
|
||||||
return await dependencies.downloadAttachmentFromServer(
|
return await dependencies.downloadAttachmentFromServer(
|
||||||
|
|
61
ts/util/downloadAttachmentFromLocalBackup.ts
Normal file
61
ts/util/downloadAttachmentFromLocalBackup.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
import {
|
||||||
|
type AttachmentType,
|
||||||
|
getAttachmentIdForLogging,
|
||||||
|
} from '../types/Attachment';
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
import { toLogFormat } from '../types/errors';
|
||||||
|
import {
|
||||||
|
decryptAndReencryptLocally,
|
||||||
|
type ReencryptedAttachmentV2,
|
||||||
|
} from '../AttachmentCrypto';
|
||||||
|
import { strictAssert } from './assert';
|
||||||
|
|
||||||
|
export class AttachmentPermanentlyUndownloadableError extends Error {}
|
||||||
|
|
||||||
|
export async function downloadAttachmentFromLocalBackup(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): Promise<ReencryptedAttachmentV2> {
|
||||||
|
const attachmentId = getAttachmentIdForLogging(attachment);
|
||||||
|
const dataId = `${attachmentId}`;
|
||||||
|
const logId = `downloadAttachmentFromLocalBackup(${dataId})`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await doDownloadFromLocalBackup(attachment, { logId });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
`${logId}: error when copying from local backup`,
|
||||||
|
toLogFormat(error)
|
||||||
|
);
|
||||||
|
throw new AttachmentPermanentlyUndownloadableError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDownloadFromLocalBackup(
|
||||||
|
attachment: AttachmentType,
|
||||||
|
{
|
||||||
|
logId,
|
||||||
|
}: {
|
||||||
|
logId: string;
|
||||||
|
}
|
||||||
|
): Promise<ReencryptedAttachmentV2> {
|
||||||
|
const { digest, localBackupPath, localKey, size } = attachment;
|
||||||
|
|
||||||
|
strictAssert(digest, `${logId}: missing digest`);
|
||||||
|
strictAssert(localKey, `${logId}: missing localKey`);
|
||||||
|
strictAssert(localBackupPath, `${logId}: missing localBackupPath`);
|
||||||
|
strictAssert(isNumber(size), `${logId}: missing size`);
|
||||||
|
|
||||||
|
return decryptAndReencryptLocally({
|
||||||
|
type: 'local',
|
||||||
|
ciphertextPath: localBackupPath,
|
||||||
|
idForLogging: logId,
|
||||||
|
keysBase64: localKey,
|
||||||
|
size,
|
||||||
|
getAbsoluteAttachmentPath:
|
||||||
|
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||||
|
});
|
||||||
|
}
|
24
ts/util/isLocalBackupsEnabled.ts
Normal file
24
ts/util/isLocalBackupsEnabled.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as RemoteConfig from '../RemoteConfig';
|
||||||
|
import { isTestOrMockEnvironment } from '../environment';
|
||||||
|
import { isStagingServer } from './isStagingServer';
|
||||||
|
import { isNightly } from './version';
|
||||||
|
|
||||||
|
export function isLocalBackupsEnabled(): boolean {
|
||||||
|
if (isStagingServer() || isTestOrMockEnvironment()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoteConfig.isEnabled('desktop.internalUser')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = window.getVersion?.();
|
||||||
|
if (version != null) {
|
||||||
|
return isNightly(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
18
ts/util/isSettingsInternalEnabled.ts
Normal file
18
ts/util/isSettingsInternalEnabled.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as RemoteConfig from '../RemoteConfig';
|
||||||
|
import { isNightly } from './version';
|
||||||
|
|
||||||
|
export function isSettingsInternalEnabled(): boolean {
|
||||||
|
if (RemoteConfig.isEnabled('desktop.internalUser')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = window.getVersion?.();
|
||||||
|
if (version != null) {
|
||||||
|
return isNightly(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
|
@ -51,6 +51,8 @@ installCallback('isInternalUser');
|
||||||
installCallback('syncRequest');
|
installCallback('syncRequest');
|
||||||
installCallback('getEmojiSkinToneDefault');
|
installCallback('getEmojiSkinToneDefault');
|
||||||
installCallback('setEmojiSkinToneDefault');
|
installCallback('setEmojiSkinToneDefault');
|
||||||
|
installCallback('exportLocalBackup');
|
||||||
|
installCallback('importLocalBackup');
|
||||||
installCallback('validateBackup');
|
installCallback('validateBackup');
|
||||||
|
|
||||||
installSetting('alwaysRelayCalls');
|
installSetting('alwaysRelayCalls');
|
||||||
|
|
|
@ -43,6 +43,7 @@ SettingsWindowProps.onRender(
|
||||||
doDeleteAllData,
|
doDeleteAllData,
|
||||||
doneRendering,
|
doneRendering,
|
||||||
editCustomColor,
|
editCustomColor,
|
||||||
|
exportLocalBackup,
|
||||||
getConversationsWithCustomColor,
|
getConversationsWithCustomColor,
|
||||||
hasAudioNotifications,
|
hasAudioNotifications,
|
||||||
hasAutoConvertEmoji,
|
hasAutoConvertEmoji,
|
||||||
|
@ -67,6 +68,7 @@ SettingsWindowProps.onRender(
|
||||||
hasStoriesDisabled,
|
hasStoriesDisabled,
|
||||||
hasTextFormatting,
|
hasTextFormatting,
|
||||||
hasTypingIndicators,
|
hasTypingIndicators,
|
||||||
|
importLocalBackup,
|
||||||
initialSpellCheckSetting,
|
initialSpellCheckSetting,
|
||||||
isAutoDownloadUpdatesSupported,
|
isAutoDownloadUpdatesSupported,
|
||||||
isAutoLaunchSupported,
|
isAutoLaunchSupported,
|
||||||
|
@ -152,6 +154,7 @@ SettingsWindowProps.onRender(
|
||||||
defaultConversationColor={defaultConversationColor}
|
defaultConversationColor={defaultConversationColor}
|
||||||
deviceName={deviceName}
|
deviceName={deviceName}
|
||||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
|
exportLocalBackup={exportLocalBackup}
|
||||||
phoneNumber={phoneNumber}
|
phoneNumber={phoneNumber}
|
||||||
doDeleteAllData={doDeleteAllData}
|
doDeleteAllData={doDeleteAllData}
|
||||||
doneRendering={doneRendering}
|
doneRendering={doneRendering}
|
||||||
|
@ -181,6 +184,7 @@ SettingsWindowProps.onRender(
|
||||||
hasTextFormatting={hasTextFormatting}
|
hasTextFormatting={hasTextFormatting}
|
||||||
hasTypingIndicators={hasTypingIndicators}
|
hasTypingIndicators={hasTypingIndicators}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
importLocalBackup={importLocalBackup}
|
||||||
initialSpellCheckSetting={initialSpellCheckSetting}
|
initialSpellCheckSetting={initialSpellCheckSetting}
|
||||||
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
|
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
|
||||||
isAutoLaunchSupported={isAutoLaunchSupported}
|
isAutoLaunchSupported={isAutoLaunchSupported}
|
||||||
|
|
|
@ -100,6 +100,8 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
|
||||||
const ipcIsSyncNotSupported = createCallback('isPrimary');
|
const ipcIsSyncNotSupported = createCallback('isPrimary');
|
||||||
const ipcIsInternalUser = createCallback('isInternalUser');
|
const ipcIsInternalUser = createCallback('isInternalUser');
|
||||||
const ipcMakeSyncRequest = createCallback('syncRequest');
|
const ipcMakeSyncRequest = createCallback('syncRequest');
|
||||||
|
const ipcExportLocalBackup = createCallback('exportLocalBackup');
|
||||||
|
const ipcImportLocalBackup = createCallback('importLocalBackup');
|
||||||
const ipcValidateBackup = createCallback('validateBackup');
|
const ipcValidateBackup = createCallback('validateBackup');
|
||||||
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
|
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
|
||||||
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
|
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
|
||||||
|
@ -369,6 +371,8 @@ async function renderPreferences() {
|
||||||
resetAllChatColors: ipcResetAllChatColors,
|
resetAllChatColors: ipcResetAllChatColors,
|
||||||
resetDefaultChatColor: ipcResetDefaultChatColor,
|
resetDefaultChatColor: ipcResetDefaultChatColor,
|
||||||
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
|
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
|
||||||
|
exportLocalBackup: ipcExportLocalBackup,
|
||||||
|
importLocalBackup: ipcImportLocalBackup,
|
||||||
validateBackup: ipcValidateBackup,
|
validateBackup: ipcValidateBackup,
|
||||||
// Limited support features
|
// Limited support features
|
||||||
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(
|
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue