diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1a1bb8f7e8..506d58939a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6600,6 +6600,18 @@ "messageformat": "Backups", "description": "Button to switch the settings view to control message & media backups" }, + "icu:Preferences__button--internal": { + "messageformat": "Internal", + "description": "Button to switch the settings view to control internal configuration" + }, + "icu:Preferences__internal__validate-backup--description": { + "messageformat": "Export encrypted backup to memory and run validation suite on it", + "description": "Description of the internal backup validation tool" + }, + "icu:Preferences__internal__validate-backup": { + "messageformat": "Validate", + "description": "Button to run internal backup validation tool" + }, "icu:Preferences--lastSynced": { "messageformat": "Last import at {date} {time}", "description": "Label for date and time of last sync operation" diff --git a/images/icons/v3/internal/internal.svg b/images/icons/v3/internal/internal.svg new file mode 100644 index 0000000000..edb0eb5a3b --- /dev/null +++ b/images/icons/v3/internal/internal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 519da7aa5f..f665c9d49a 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -120,6 +120,10 @@ $secondary-text-color: light-dark( &--backups { @include preferences-icon('../images/icons/v3/backup/backup-bold.svg'); } + + &--internal { + @include preferences-icon('../images/icons/v3/internal/internal.svg'); + } } &__settings-pane { @@ -539,3 +543,20 @@ $secondary-text-color: light-dark( } } } + +.Preferences--internal--validate-backup--result { + padding-inline: 48px 24px; +} + +.Preferences--internal--validate-backup--error { + padding-inline: 48px 24px; + color: variables.$color-accent-red; +} + +.Preferences--internal--validate-backup--result pre, +.Preferences--internal--validate-backup--error pre { + max-height: 128px; + max-width: 100%; + white-space: pre-wrap; + user-select: text; +} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 9985a63923..b21f27c22a 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -113,6 +113,7 @@ export default { isNotificationAttentionSupported: true, isSyncSupported: true, isSystemTraySupported: true, + isInternalUser: false, isMinimizeToAndStartInSystemTraySupported: true, lastSyncTime: Date.now(), localeOverride: null, @@ -189,6 +190,22 @@ export default { setGlobalDefaultConversationColor: action( 'setGlobalDefaultConversationColor' ), + validateBackup: async () => { + return { + totalBytes: 100, + stats: { + adHocCalls: 1, + callLinks: 2, + conversations: 3, + chats: 4, + distributionLists: 5, + messages: 6, + skippedMessages: 7, + stickerPacks: 8, + fixedDirectMessages: 9, + }, + }; + }, } satisfies PropsType, } satisfies Meta; @@ -300,3 +317,9 @@ BackupsSubscriptionExpired.args = { status: 'expired', }, }; + +export const Internal = Template.bind({}); +Internal.args = { + initialPage: Page.Internal, + isInternalUser: true, +}; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index d27dde4be7..634776474c 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -14,6 +14,7 @@ import classNames from 'classnames'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import type { MediaDeviceSettings } from '../types/Calling'; +import type { ExportResultType as BackupExportResultType } from '../services/backups'; import type { AutoDownloadAttachmentType, NotificationSettingType, @@ -75,6 +76,7 @@ import { SettingsRow, } from './PreferencesUtil'; import { PreferencesBackups } from './PreferencesBackups'; +import { PreferencesInternal } from './PreferencesInternal'; import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider'; type CheckboxChangeHandlerType = (value: boolean) => unknown; @@ -145,6 +147,7 @@ export type PropsDataType = { isSyncSupported: boolean; isSystemTraySupported: boolean; isMinimizeToAndStartInSystemTraySupported: boolean; + isInternalUser: boolean; availableCameras: Array< Pick @@ -175,6 +178,7 @@ type PropsFunctionType = { value: CustomColorType; } ) => unknown; + validateBackup: () => Promise; // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; @@ -232,6 +236,7 @@ export enum Page { Privacy = 'Privacy', DataUsage = 'DataUsage', Backups = 'Backups', + Internal = 'Internal', // Sub pages ChatColor = 'ChatColor', @@ -319,6 +324,7 @@ export function Preferences({ isSyncSupported, isSystemTraySupported, isMinimizeToAndStartInSystemTraySupported, + isInternalUser, lastSyncTime, makeSyncRequest, notificationContent, @@ -373,6 +379,7 @@ export function Preferences({ localeOverride, themeSetting, universalExpireTimer = DurationInSeconds.ZERO, + validateBackup, whoCanFindMe, whoCanSeeMe, zoomFactor, @@ -410,6 +417,9 @@ export function Preferences({ if (page === Page.Backups && !shouldShowBackupsPage) { setPage(Page.General); } + if (page === Page.Internal && !isInternalUser) { + setPage(Page.General); + } useEffect(() => { if (page === Page.Backups) { @@ -1728,6 +1738,10 @@ export function Preferences({ locale={resolvedLocale} /> ); + } else if (page === Page.Internal) { + settings = ( + + ); } return ( @@ -1829,6 +1843,19 @@ export function Preferences({ {i18n('icu:Preferences__button--backups')} ) : null} + {isInternalUser ? ( + + ) : null}
{settings} diff --git a/ts/components/PreferencesInternal.tsx b/ts/components/PreferencesInternal.tsx new file mode 100644 index 0000000000..076ff3bfee --- /dev/null +++ b/ts/components/PreferencesInternal.tsx @@ -0,0 +1,104 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState, useCallback } from 'react'; +import type { LocalizerType } from '../types/I18N'; +import { toLogFormat } from '../types/errors'; +import { formatFileSize } from '../util/formatFileSize'; +import type { ExportResultType as BackupExportResultType } from '../services/backups'; +import { SettingsRow, SettingsControl } from './PreferencesUtil'; +import { Button, ButtonVariant } from './Button'; +import { Spinner } from './Spinner'; + +export function PreferencesInternal({ + i18n, + validateBackup: doValidateBackup, +}: { + i18n: LocalizerType; + validateBackup: () => Promise; +}): JSX.Element { + const [isValidationPending, setIsValidationPending] = useState(false); + const [validationResult, setValidationResult] = useState< + | { + result: BackupExportResultType; + } + | { + error: Error; + } + | undefined + >(); + + const validateBackup = useCallback(async () => { + setIsValidationPending(true); + setValidationResult(undefined); + try { + setValidationResult({ result: await doValidateBackup() }); + } catch (error) { + setValidationResult({ error }); + } finally { + setIsValidationPending(false); + } + }, [doValidateBackup]); + + let validationElem: JSX.Element | undefined; + if (validationResult != null) { + if ('result' in validationResult) { + const { + result: { totalBytes, stats }, + } = validationResult; + + validationElem = ( +
+

File size: {formatFileSize(totalBytes)}

+
+            {JSON.stringify(stats, null, 2)}
+          
+
+ ); + } else { + const { error } = validationResult; + + validationElem = ( +
+
+            {toLogFormat(error)}
+          
+
+ ); + } + } + + return ( + <> +
+
+ {i18n('icu:Preferences__button--internal')} +
+
+ + + + {isValidationPending ? ( + + ) : ( + i18n('icu:Preferences__internal__validate-backup') + )} + + } + /> + + {validationElem} + + + ); +} diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index 674282ae7c..f7293d8548 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -64,9 +64,11 @@ export class SettingsChannel extends EventEmitter { this.#installCallback('deleteAllMyStories'); this.#installCallback('getAvailableIODevices'); this.#installCallback('isPrimary'); + this.#installCallback('isInternalUser'); this.#installCallback('syncRequest'); this.#installCallback('setEmojiSkinToneDefault'); this.#installCallback('getEmojiSkinToneDefault'); + this.#installCallback('validateBackup'); // Backups this.#installSetting('backupFeatureEnabled', { setter: false }); diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 1dec5d07e7..92211c0cb9 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -204,6 +204,18 @@ type NonBubbleResultType = Readonly< } >; +export type StatsType = { + adHocCalls: number; + callLinks: number; + conversations: number; + chats: number; + distributionLists: number; + messages: number; + skippedMessages: number; + stickerPacks: number; + fixedDirectMessages: number; +}; + export class BackupExportStream extends Readable { // Shared between all methods for consistency. #now = Date.now(); @@ -213,7 +225,7 @@ export class BackupExportStream extends Readable { readonly #serviceIdToRecipientId = new Map(); readonly #e164ToRecipientId = new Map(); readonly #roomIdToRecipientId = new Map(); - readonly #stats = { + readonly #stats: StatsType = { adHocCalls: 0, callLinks: 0, conversations: 0, @@ -270,6 +282,10 @@ export class BackupExportStream extends Readable { ); } + public getStats(): Readonly { + return this.#stats; + } + async #unsafeRun(backupLevel: BackupLevel): Promise { this.#ourConversation = window.ConversationController.getOurConversationOrThrow().attributes; diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 2f7c0bdc31..009ed1ec0b 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -47,7 +47,7 @@ import { constantTimeEqual } from '../../Crypto'; import { measureSize } from '../../AttachmentCrypto'; import { isTestOrMockEnvironment } from '../../environment'; import { runStorageServiceSyncJob } from '../storage'; -import { BackupExportStream } from './export'; +import { BackupExportStream, type StatsType } from './export'; import { BackupImportStream } from './import'; import { getKeyMaterial } from './crypto'; import { BackupCredentials } from './credentials'; @@ -61,6 +61,8 @@ import { BackupProcessingError, RelinkRequestedError, } from './errors'; +import { FileStream } from './util/FileStream'; +import { MemoryStream } from './util/MemoryStream'; import { ToastType } from '../../types/Toast'; import { isAdhoc, isNightly } from '../../util/version'; import { getMessageQueueTime } from '../../util/getMessageQueueTime'; @@ -96,6 +98,11 @@ export type ImportOptionsType = Readonly<{ onProgress?: (currentBytes: number, totalBytes: number) => void; }>; +export type ExportResultType = Readonly<{ + totalBytes: number; + stats: Readonly; +}>; + export class BackupsService { #isStarted = false; #isRunning: 'import' | 'export' | false = false; @@ -289,14 +296,17 @@ export class BackupsService { public async exportBackupData( backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext - ): Promise { + ): Promise<{ data: Uint8Array } & ExportResultType> { const sink = new PassThrough(); const chunks = new Array(); sink.on('data', chunk => chunks.push(chunk)); - await this.#exportBackup(sink, backupLevel, backupType); + const result = await this.#exportBackup(sink, backupLevel, backupType); - return Bytes.concatenate(chunks); + return { + ...result, + data: Bytes.concatenate(chunks), + }; } // Test harness @@ -305,22 +315,38 @@ export class BackupsService { backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext ): Promise { - const size = await this.#exportBackup( + const { totalBytes } = await this.#exportBackup( createWriteStream(path), backupLevel, backupType ); if (backupType === BackupType.Ciphertext) { - await validateBackup(path, size); + await validateBackup(() => new FileStream(path), totalBytes); } - return size; + return totalBytes; + } + + // Test harness + public async validate( + backupLevel: BackupLevel = BackupLevel.Free, + backupType = BackupType.Ciphertext + ): Promise { + const { data, ...result } = await this.exportBackupData( + backupLevel, + backupType + ); + const buffer = Buffer.from(data); + + await validateBackup(() => new MemoryStream(buffer), buffer.byteLength); + + return result; } // Test harness public async exportWithDialog(): Promise { - const data = await this.exportBackupData(); + const { data } = await this.exportBackupData(); const { saveAttachmentToDisk } = window.Signal.Migrations; @@ -712,7 +738,7 @@ export class BackupsService { sink: Writable, backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext - ): Promise { + ): Promise { strictAssert(!this.#isRunning, 'BackupService is already running'); log.info('exportBackup: starting...'); @@ -766,7 +792,7 @@ export class BackupsService { throw missingCaseError(backupType); } - return totalBytes; + return { totalBytes, stats: recordStream.getStats() }; } finally { log.info('exportBackup: finished...'); this.#isRunning = false; diff --git a/ts/services/backups/util/MemoryStream.ts b/ts/services/backups/util/MemoryStream.ts new file mode 100644 index 0000000000..220412a94a --- /dev/null +++ b/ts/services/backups/util/MemoryStream.ts @@ -0,0 +1,23 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { type Buffer } from 'node:buffer'; +import { InputStream } from '@signalapp/libsignal-client/dist/io'; + +export class MemoryStream extends InputStream { + #offset = 0; + + constructor(private readonly buffer: Buffer) { + super(); + } + + public override async read(amount: number): Promise { + const result = this.buffer.subarray(this.#offset, this.#offset + amount); + this.#offset += amount; + return result; + } + + public override async skip(amount: number): Promise { + this.#offset += amount; + } +} diff --git a/ts/services/backups/validator.ts b/ts/services/backups/validator.ts index 514eaf1002..599a867733 100644 --- a/ts/services/backups/validator.ts +++ b/ts/services/backups/validator.ts @@ -2,14 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as libsignal from '@signalapp/libsignal-client/dist/MessageBackup'; +import type { InputStream } from '@signalapp/libsignal-client/dist/io'; import { strictAssert } from '../../util/assert'; import { toAciObject } from '../../util/ServiceId'; import { isTestOrMockEnvironment } from '../../environment'; -import { FileStream } from './util/FileStream'; export async function validateBackup( - filePath: string, + inputFactory: () => InputStream, fileSize: number ): Promise { const accountEntropy = window.storage.get('accountEntropyPool'); @@ -24,7 +24,7 @@ export async function validateBackup( const outcome = await libsignal.validate( backupKey, libsignal.Purpose.RemoteBackup, - () => new FileStream(filePath), + inputFactory, BigInt(fileSize) ); diff --git a/ts/test-electron/backup/integration_test.ts b/ts/test-electron/backup/integration_test.ts index ecb719d3e8..f8dc94232f 100644 --- a/ts/test-electron/backup/integration_test.ts +++ b/ts/test-electron/backup/integration_test.ts @@ -6,7 +6,6 @@ import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Readable } from 'node:stream'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; -import { InputStream } from '@signalapp/libsignal-client/dist/io'; import { ComparableBackup, Purpose, @@ -16,29 +15,12 @@ import { assert } from 'chai'; import { clearData } from './helpers'; import { loadAllAndReinitializeRedux } from '../../services/allLoaders'; import { backupsService, BackupType } from '../../services/backups'; +import { MemoryStream } from '../../services/backups/util/MemoryStream'; import { initialize as initializeExpiringMessageService } from '../../services/expiringMessagesDeletion'; import { DataWriter } from '../../sql/Client'; const { BACKUP_INTEGRATION_DIR } = process.env; -class MemoryStream extends InputStream { - #offset = 0; - - constructor(private readonly buffer: Buffer) { - super(); - } - - public override async read(amount: number): Promise { - const result = this.buffer.slice(this.#offset, this.#offset + amount); - this.#offset += amount; - return result; - } - - public override async skip(amount: number): Promise { - this.#offset += amount; - } -} - describe('backup/integration', () => { before(async () => { await initializeExpiringMessageService(); @@ -75,7 +57,7 @@ describe('backup/integration', () => { backupType: BackupType.TestOnlyPlaintext, }); - const exported = await backupsService.exportBackupData( + const { data: exported } = await backupsService.exportBackupData( BackupLevel.Paid, BackupType.TestOnlyPlaintext ); diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 8932b4d41c..5057800f87 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -23,6 +23,10 @@ import type { ConversationType } from '../state/ducks/conversations'; import { calling } from '../services/calling'; import { resolveUsernameByLinkBase64 } from '../services/username'; import { writeProfile } from '../services/writeProfile'; +import { + backupsService, + type ExportResultType as BackupExportResultType, +} from '../services/backups'; import { isInCall } from '../state/selectors/calling'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getCustomColors } from '../state/selectors/items'; @@ -65,6 +69,7 @@ import type { BackupStatusType, } from '../types/backups'; import { isBackupFeatureEnabled } from './isBackupEnabled'; +import * as RemoteConfig from '../RemoteConfig'; type SentMediaQualityType = 'standard' | 'high'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; @@ -135,6 +140,7 @@ export type IPCEventsCallbacksType = { ) => Promise>; installStickerPack: (packId: string, key: string) => Promise; isPrimary: () => boolean; + isInternalUser: () => boolean; removeCustomColor: (x: string) => void; removeCustomColorOnConversations: (x: string) => void; removeDarkOverlay: () => void; @@ -158,6 +164,7 @@ export type IPCEventsCallbacksType = { unknownSignalLink: () => void; getCustomColors: () => Record; syncRequest: () => Promise; + validateBackup: () => Promise; setGlobalDefaultConversationColor: ( color: ConversationColorType, customColor?: { id: string; value: CustomColorType } @@ -535,6 +542,7 @@ export function createIPCEvents( }, isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1, + isInternalUser: () => RemoteConfig.isEnabled('desktop.internalUser'), syncRequest: async () => { const contactSyncComplete = waitForEvent( 'contactSync:complete', @@ -543,6 +551,7 @@ export function createIPCEvents( await sendSyncRequests(); return contactSyncComplete; }, + validateBackup: () => backupsService.validate(), getLastSyncTime: () => window.storage.get('synced_at'), setLastSyncTime: value => window.storage.put('synced_at', value), getUniversalExpireTimer: () => universalExpireTimer.get(), diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 415c136c25..6e55218282 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -47,9 +47,11 @@ installCallback('refreshCloudBackupStatus'); installCallback('refreshBackupSubscriptionStatus'); installCallback('deleteAllMyStories'); installCallback('isPrimary'); +installCallback('isInternalUser'); installCallback('syncRequest'); installCallback('getEmojiSkinToneDefault'); installCallback('setEmojiSkinToneDefault'); +installCallback('validateBackup'); installSetting('alwaysRelayCalls'); installSetting('audioMessage'); diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx index 8ecc53f35b..bda771947f 100644 --- a/ts/windows/settings/app.tsx +++ b/ts/windows/settings/app.tsx @@ -75,6 +75,7 @@ SettingsWindowProps.onRender( isNotificationAttentionSupported, isSyncSupported, isSystemTraySupported, + isInternalUser, lastSyncTime, makeSyncRequest, notificationContent, @@ -128,6 +129,7 @@ SettingsWindowProps.onRender( localeOverride, themeSetting, universalExpireTimer, + validateBackup, whoCanFindMe, whoCanSeeMe, zoomFactor, @@ -188,6 +190,7 @@ SettingsWindowProps.onRender( isNotificationAttentionSupported={isNotificationAttentionSupported} isSyncSupported={isSyncSupported} isSystemTraySupported={isSystemTraySupported} + isInternalUser={isInternalUser} lastSyncTime={lastSyncTime} localeOverride={localeOverride} makeSyncRequest={makeSyncRequest} @@ -243,6 +246,7 @@ SettingsWindowProps.onRender( setGlobalDefaultConversationColor={setGlobalDefaultConversationColor} themeSetting={themeSetting} universalExpireTimer={universalExpireTimer} + validateBackup={validateBackup} whoCanFindMe={whoCanFindMe} whoCanSeeMe={whoCanSeeMe} zoomFactor={zoomFactor} diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index 4bbed65dc0..cbc606801a 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -98,7 +98,9 @@ const ipcGetAvailableIODevices = createCallback('getAvailableIODevices'); const ipcGetCustomColors = createCallback('getCustomColors'); const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault'); const ipcIsSyncNotSupported = createCallback('isPrimary'); +const ipcIsInternalUser = createCallback('isInternalUser'); const ipcMakeSyncRequest = createCallback('syncRequest'); +const ipcValidateBackup = createCallback('validateBackup'); const ipcDeleteAllMyStories = createCallback('deleteAllMyStories'); const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus'); const ipcRefreshBackupSubscriptionStatus = createCallback( @@ -205,6 +207,7 @@ async function renderPreferences() { customColors, defaultConversationColor, isSyncNotSupported, + isInternalUser, } = await awaitObject({ autoDownloadAttachment: settingAutoDownloadAttachment.getValue(), backupFeatureEnabled: settingBackupFeatureEnabled.getValue(), @@ -253,6 +256,7 @@ async function renderPreferences() { defaultConversationColor: ipcGetDefaultConversationColor(), emojiSkinToneDefault: ipcGetEmojiSkinToneDefault(), isSyncNotSupported: ipcIsSyncNotSupported(), + isInternalUser: ipcIsInternalUser(), }); const { availableCameras, availableMicrophones, availableSpeakers } = @@ -365,6 +369,7 @@ async function renderPreferences() { resetAllChatColors: ipcResetAllChatColors, resetDefaultChatColor: ipcResetDefaultChatColor, setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor, + validateBackup: ipcValidateBackup, // Limited support features isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported( OS, @@ -374,6 +379,7 @@ async function renderPreferences() { isHideMenuBarSupported: Settings.isHideMenuBarSupported(OS), isNotificationAttentionSupported: Settings.isDrawAttentionSupported(OS), isSyncSupported: !isSyncNotSupported, + isInternalUser, isSystemTraySupported: Settings.isSystemTraySupported(OS), isMinimizeToAndStartInSystemTraySupported: Settings.isMinimizeToAndStartInSystemTraySupported(OS),