diff --git a/docs/UPSTREAM-MERGE-GUIDE.md b/docs/UPSTREAM-MERGE-GUIDE.md index d8a0ea868f..4ea762e38d 100644 --- a/docs/UPSTREAM-MERGE-GUIDE.md +++ b/docs/UPSTREAM-MERGE-GUIDE.md @@ -1,3 +1,8 @@ + + # Observer Vault: Upstream Merge Guide This document describes Observer Vault's customizations from Signal-Desktop and how to merge upstream releases. @@ -181,10 +186,10 @@ Upstream changes function signatures: // CONFLICT: Function signature changed // SOLUTION: Update to new signature but keep our logic export function shouldShowCallQualitySurvey( - newParam: NewType, // Accept new parameter + newParam: NewType, // Accept new parameter cqsTestMode?: boolean ): boolean { - return false; // Keep our disabled implementation + return false; // Keep our disabled implementation } ``` diff --git a/scripts/audit-customizations.sh b/scripts/audit-customizations.sh index ea8f826265..128bf0f095 100755 --- a/scripts/audit-customizations.sh +++ b/scripts/audit-customizations.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Copyright 2026 Lockdown Systems LLC +# SPDX-License-Identifier: AGPL-3.0-only # # List all Observer Vault customizations # diff --git a/scripts/fix-ringrtc-imports.sh b/scripts/fix-ringrtc-imports.sh index 31c69ba3e3..23a8007c28 100755 --- a/scripts/fix-ringrtc-imports.sh +++ b/scripts/fix-ringrtc-imports.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Copyright 2026 Lockdown Systems LLC +# SPDX-License-Identifier: AGPL-3.0-only # # Fix RingRTC imports after merge # diff --git a/scripts/merge-upstream.sh b/scripts/merge-upstream.sh index 10a5c8a3fd..0d3a3dc9da 100755 --- a/scripts/merge-upstream.sh +++ b/scripts/merge-upstream.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Copyright 2026 Lockdown Systems LLC +# SPDX-License-Identifier: AGPL-3.0-only # # Observer Vault: Merge Upstream Signal-Desktop # diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 84705d69ac..96bdda6c0b 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -557,9 +557,8 @@ export async function startApp(): Promise { // Observer Vault: Initialize settings (stories off, camera/mic disabled) drop( (async () => { - const { initializeObserverVaultSettings } = await import( - './observervault/initializeSettings.preload.js' - ); + const { initializeObserverVaultSettings } = + await import('./observervault/initializeSettings.preload.js'); await initializeObserverVaultSettings(); })() ); diff --git a/ts/calling/VideoSupport.preload.ts b/ts/calling/VideoSupport.preload.ts index 616b0ef1b7..8f559e89bd 100644 --- a/ts/calling/VideoSupport.preload.ts +++ b/ts/calling/VideoSupport.preload.ts @@ -5,7 +5,10 @@ // Observer Vault: videoPixelFormatToEnum unused (spawnSender removed) // import { videoPixelFormatToEnum } from '@lockdown-systems/ringrtc'; -import type { VideoFrameSender, VideoFrameSource } from '@lockdown-systems/ringrtc'; +import type { + VideoFrameSender, + VideoFrameSource, +} from '@lockdown-systems/ringrtc'; import type { RefObject } from 'react'; import { createLogger } from '../logging/log.std.js'; // Observer Vault: toLogFormat unused (startCapturing removed) diff --git a/ts/messageModifiers/AttachmentDownloads.preload.ts b/ts/messageModifiers/AttachmentDownloads.preload.ts index 0d9a1002aa..2fd59b3790 100644 --- a/ts/messageModifiers/AttachmentDownloads.preload.ts +++ b/ts/messageModifiers/AttachmentDownloads.preload.ts @@ -16,6 +16,9 @@ import { } from '../util/migrations.preload.js'; import { getMessageById } from '../messages/getMessageById.preload.js'; import { trimMessageWhitespace } from '../types/BodyRange.std.js'; +// Observer Vault: Import the immediate save function +import { saveAttachmentToObserverVault } from '../observervault/messageHandler.preload.js'; +import { drop } from '../util/drop.std.js'; const { omit } = lodash; @@ -88,6 +91,19 @@ export async function addAttachmentToMessage( return; } + // Observer Vault: Immediately save downloaded attachments to avoid loss from + // message expiration. This runs synchronously with download completion. + if (type === 'attachment' && isDownloaded(attachment)) { + drop( + saveAttachmentToObserverVault(attachment, messageId).catch(error => { + log.error( + `${logPrefix}: Observer Vault save failed:`, + error instanceof Error ? error.message : String(error) + ); + }) + ); + } + if (type === 'long-message') { let handledAnywhere = false; let attachmentData: Uint8Array | undefined; diff --git a/ts/observervault/initializeSettings.preload.ts b/ts/observervault/initializeSettings.preload.ts index 92c4791ee3..f1096227e7 100644 --- a/ts/observervault/initializeSettings.preload.ts +++ b/ts/observervault/initializeSettings.preload.ts @@ -26,7 +26,7 @@ export async function initializeObserverVaultSettings(): Promise { try { const { getStoriesDisabled } = await import('../util/stories.preload.js'); const storiesDisabled = getStoriesDisabled(); - + if (!storiesDisabled) { log.info('Stories not disabled, disabling now...'); await setStoriesDisabled(true); @@ -41,7 +41,7 @@ export async function initializeObserverVaultSettings(): Promise { // 2. Disable microphone access try { const hasMediaPermissions = await window.Events.getMediaPermissions(); - + if (hasMediaPermissions !== false) { log.info('Microphone access not disabled, disabling now...'); await window.IPC.setMediaPermissions(false); @@ -55,8 +55,9 @@ export async function initializeObserverVaultSettings(): Promise { // 3. Disable camera access try { - const hasMediaCameraPermissions = await window.Events.getMediaCameraPermissions(); - + const hasMediaCameraPermissions = + await window.Events.getMediaCameraPermissions(); + if (hasMediaCameraPermissions !== false) { log.info('Camera access not disabled, disabling now...'); await window.IPC.setMediaCameraPermissions(false); diff --git a/ts/observervault/messageHandler.preload.ts b/ts/observervault/messageHandler.preload.ts index 2c1db8df8a..98bab7621d 100644 --- a/ts/observervault/messageHandler.preload.ts +++ b/ts/observervault/messageHandler.preload.ts @@ -9,6 +9,9 @@ * 1. Auto-reply functionality for text messages * 2. Automatic disappearing messages timer configuration * 3. Auto-download of all attachments to Downloads folder + * + * IMPORTANT: Attachments are downloaded DIRECTLY (bypassing Signal's queue) + * to ensure they are saved before the 30-second disappearing timer expires. */ import { homedir } from 'node:os'; @@ -19,20 +22,30 @@ import { createLogger } from '../logging/log.std.js'; import { drop } from '../util/drop.std.js'; import { DurationInSeconds } from '../util/durations/index.std.js'; import { sleep } from '../util/sleep.std.js'; +import * as RemoteConfig from '../RemoteConfig.dom.js'; import { isDirectConversation, isGroupV2, } from '../util/whatTypeOfConversation.dom.js'; -import { isDownloaded } from '../util/Attachment.std.js'; +import { + isDownloaded, + hasRequiredInformationToDownloadFromTransitTier, +} from '../util/Attachment.std.js'; import { loadAttachmentData, saveAttachmentToDisk, getUnusedFilename, + processNewAttachment, } from '../util/migrations.preload.js'; +import { downloadAttachment } from '../util/downloadAttachment.preload.js'; import type { AttachmentType } from '../types/Attachment.std.js'; import type { ConversationModel } from '../models/conversations.preload.js'; import type { MessageModel } from '../models/messages.preload.js'; import { getMessageById } from '../messages/getMessageById.preload.js'; +import { + getMaximumIncomingAttachmentSizeInKb, + KIBIBYTE, +} from '../types/AttachmentSize.std.js'; const log = createLogger('observervault/messageHandler'); @@ -104,9 +117,287 @@ function getExtensionFromContentType( return 'bin'; } +// Track which attachments we've already saved to avoid duplicates +const savedAttachmentPaths = new Set(); +// Track which attachment digests we've already started downloading +const downloadingAttachments = new Set(); + +/** + * Directly downloads an attachment from Signal's servers and saves it to disk. + * This bypasses Signal's download queue to ensure the attachment is saved + * before the 30-second disappearing message timer expires. + */ +async function directDownloadAndSave( + attachment: AttachmentType, + logId: string +): Promise { + const attachmentId = attachment.digest || attachment.cdnKey || 'unknown'; + + // Skip if already downloading or downloaded + if (downloadingAttachments.has(attachmentId)) { + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Already downloading ${attachmentId}` + ); + return false; + } + + // Skip if already downloaded (has a path) + if (attachment.path && savedAttachmentPaths.has(attachment.path)) { + // eslint-disable-next-line no-console + console.log(`[Observer Vault] ${logId}: Already saved ${attachment.path}`); + return true; + } + + // Check if we have enough info to download + if (!hasRequiredInformationToDownloadFromTransitTier(attachment)) { + // eslint-disable-next-line no-console + console.warn( + `[Observer Vault] ${logId}: Attachment missing download info (cdnKey/key/digest)` + ); + return false; + } + + // Check size limit + const maxSizeKb = getMaximumIncomingAttachmentSizeInKb(RemoteConfig.getValue); + if (attachment.size > maxSizeKb * KIBIBYTE) { + // eslint-disable-next-line no-console + console.warn( + `[Observer Vault] ${logId}: Attachment too large (${attachment.size} > ${maxSizeKb * KIBIBYTE})` + ); + return false; + } + + downloadingAttachments.add(attachmentId); + + try { + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Direct downloading attachment ${attachmentId} (${attachment.size} bytes)` + ); + + // Create an abort controller with a 2-minute timeout + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 120000); + + try { + // Download the attachment directly + const downloadedAttachment = await downloadAttachment({ + attachment, + options: { + onSizeUpdate: () => { + // We don't need progress updates + }, + abortSignal: abortController.signal, + hasMediaBackups: false, + logId: `${logId}/direct`, + messageExpiresAt: null, // Don't check expiration - we want to download anyway + }, + }); + + clearTimeout(timeoutId); + + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Download complete, processing attachment` + ); + + // Process the attachment (decrypt and save to Signal's storage) + const processedAttachment = await processNewAttachment( + { + ...attachment, + ...downloadedAttachment, + }, + 'attachment' + ); + + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Processed, path=${processedAttachment.path}` + ); + + if (!processedAttachment.path) { + // eslint-disable-next-line no-console + console.error(`[Observer Vault] ${logId}: No path after processing`); + return false; + } + + // Now load the data and save to ObserverVault folder + const attachmentWithData = await loadAttachmentData(processedAttachment); + + if (!attachmentWithData.data) { + // eslint-disable-next-line no-console + console.error(`[Observer Vault] ${logId}: No data after loading`); + return false; + } + + // Generate filename + const timestamp = Date.now(); + const ext = getExtensionFromContentType( + attachment.contentType, + attachment.fileName + ); + const baseName = + attachment.fileName || `signal-attachment-${timestamp}.${ext}`; + + const uniqueName = getUnusedFilename({ + filename: baseName, + baseDir: DOWNLOADS_DIR, + }); + + // Save to disk + const result = await saveAttachmentToDisk({ + data: attachmentWithData.data, + name: uniqueName, + baseDir: DOWNLOADS_DIR, + }); + + if (result) { + savedAttachmentPaths.add(processedAttachment.path); + + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: SUCCESS - Saved to ${result.fullPath}` + ); + + // Show notification + try { + // eslint-disable-next-line no-new + new window.Notification('Observer Vault', { + body: `File saved: ${uniqueName}`, + silent: true, + }); + } catch { + // Notification may fail in some contexts + } + + return true; + } + + return false; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[Observer Vault] ${logId}: Direct download failed:`, error); + return false; + } finally { + downloadingAttachments.delete(attachmentId); + } +} + +/** + * Immediately saves a downloaded attachment to the ObserverVault folder. + * This is called directly from addAttachmentToMessage when an attachment + * download completes, ensuring the file is saved before the message can expire. + * + * This is the primary save mechanism - it runs synchronously with download completion. + */ +export async function saveAttachmentToObserverVault( + attachment: AttachmentType, + messageId: string +): Promise { + const logId = `saveAttachmentToObserverVault/${messageId}`; + + // Skip if no path or already saved + if (!attachment.path) { + // eslint-disable-next-line no-console + console.warn(`[Observer Vault] ${logId}: Attachment has no path`); + return; + } + + // Check if we've already saved this attachment + if (savedAttachmentPaths.has(attachment.path)) { + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Already saved ${attachment.path}, skipping` + ); + return; + } + + try { + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Saving attachment immediately from ${attachment.path}` + ); + + // Load the decrypted attachment data + const attachmentWithData = await loadAttachmentData(attachment); + + if (!attachmentWithData.data) { + // eslint-disable-next-line no-console + console.error(`[Observer Vault] ${logId}: No data in attachment`); + return; + } + + // Generate filename + const timestamp = Date.now(); + const ext = getExtensionFromContentType( + attachment.contentType, + attachment.fileName + ); + const baseName = + attachment.fileName || `signal-attachment-${timestamp}.${ext}`; + + const uniqueName = getUnusedFilename({ + filename: baseName, + baseDir: DOWNLOADS_DIR, + }); + + // eslint-disable-next-line no-console + console.log(`[Observer Vault] ${logId}: Saving as ${uniqueName}`); + + // Save to disk + const result = await saveAttachmentToDisk({ + data: attachmentWithData.data, + name: uniqueName, + baseDir: DOWNLOADS_DIR, + }); + + if (result) { + // Mark as saved to prevent duplicate saves + savedAttachmentPaths.add(attachment.path); + + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: SUCCESS - Saved to ${result.fullPath}` + ); + + // Show notification + try { + // eslint-disable-next-line no-new + new window.Notification('Observer Vault', { + body: `File saved: ${uniqueName}`, + silent: true, + }); + } catch { + // Notification may fail in some contexts, that's OK + } + + // Clean up old entries from the set to prevent memory leaks + // Keep the last 1000 entries + if (savedAttachmentPaths.size > 1000) { + const entries = Array.from(savedAttachmentPaths); + entries.slice(0, entries.length - 1000).forEach(path => { + savedAttachmentPaths.delete(path); + }); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[Observer Vault] ${logId}: Error saving attachment:`, error); + throw error; + } +} + /** * Waits for attachments to be downloaded by Signal's normal flow, * then saves them to the Downloads folder. + * + * NOTE: This is now a BACKUP mechanism. The primary save happens in + * saveAttachmentToObserverVault which is called directly when attachments + * are downloaded. This function catches any attachments that were missed. */ export async function downloadAllAttachments( message: MessageModel, @@ -155,8 +446,17 @@ export async function downloadAllAttachments( // Save any newly downloaded attachments to Downloads folder for (const attachment of downloadedAttachments) { - // Skip if already saved or no path - if (savedFiles.some(f => f === attachment.path) || !attachment.path) { + // Skip if already saved by the primary save mechanism or no path + if (!attachment.path) { + continue; + } + if (savedFiles.some(f => f === attachment.path)) { + continue; + } + if (savedAttachmentPaths.has(attachment.path)) { + // Already saved by saveAttachmentToObserverVault + savedFiles.push(attachment.path); + downloadedCount += 1; continue; } @@ -207,6 +507,7 @@ export async function downloadAllAttachments( `[Observer Vault] ${logId}: SUCCESS - Saved to ${result.fullPath}` ); savedFiles.push(attachment.path); + savedAttachmentPaths.add(attachment.path); downloadedCount += 1; } } catch (error) { @@ -344,13 +645,53 @@ export async function handleObserverVaultIncomingMessage( }); log.info(`${logId}: Auto-selected conversation to mark as read`); - // Check for attachments and download them + // Check for attachments and download them IMMEDIATELY + // We use direct download to bypass Signal's queue and ensure attachments + // are saved before the 30-second disappearing timer expires const attachments = message.get('attachments') || []; if (attachments.length > 0) { + const messageId = message.get('id'); // eslint-disable-next-line no-console console.log( - `[Observer Vault] ${logId}: Message has ${attachments.length} attachment(s), starting download` + `[Observer Vault] ${logId}: Message has ${attachments.length} attachment(s), starting DIRECT download` ); + + // Download all attachments in parallel, directly bypassing Signal's queue + const downloadPromises = attachments.map(async (attachment, index) => { + const attachmentLogId = `${logId}/attachment-${index}`; + try { + // First check if already downloaded + if (isDownloaded(attachment) && attachment.path) { + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${attachmentLogId}: Already downloaded, saving` + ); + await saveAttachmentToObserverVault(attachment, messageId); + return true; + } + + // Direct download and save + return await directDownloadAndSave(attachment, attachmentLogId); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `[Observer Vault] ${attachmentLogId}: Failed to download:`, + error + ); + return false; + } + }); + + // Wait for all downloads to complete (don't use drop() here!) + const results = await Promise.all(downloadPromises); + const successCount = results.filter(Boolean).length; + + // eslint-disable-next-line no-console + console.log( + `[Observer Vault] ${logId}: Downloaded ${successCount}/${attachments.length} attachments` + ); + + // Also start the backup polling mechanism in case direct download fails drop(downloadAllAttachments(message, conversation)); } diff --git a/ts/scripts/sign-windows.node.ts b/ts/scripts/sign-windows.node.ts index 205e862516..26d4af5735 100644 --- a/ts/scripts/sign-windows.node.ts +++ b/ts/scripts/sign-windows.node.ts @@ -1,7 +1,8 @@ // Copyright 2019 Signal Messenger, LLC -// Copyright 2026 Lockdown Systems LLC // SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026 Lockdown Systems LLC + import { execSync } from 'node:child_process'; import fsExtra from 'fs-extra'; diff --git a/ts/state/ducks/installer.preload.ts b/ts/state/ducks/installer.preload.ts index 55aa087485..3346667cbd 100644 --- a/ts/state/ducks/installer.preload.ts +++ b/ts/state/ducks/installer.preload.ts @@ -4,10 +4,7 @@ import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; -import { - ErrorCode, - LibSignalErrorBase, -} from '@signalapp/libsignal-client'; +import { ErrorCode, LibSignalErrorBase } from '@signalapp/libsignal-client'; import type { StateType as RootStateType } from '../reducer.preload.js'; import { InstallScreenStep, @@ -272,8 +269,7 @@ function submitCaptcha( if (error instanceof LibSignalErrorBase) { switch (error.code) { case ErrorCode.RateLimitedError: { - const retryAfterSecs = (error as { retryAfterSecs?: number }) - .retryAfterSecs; + const { retryAfterSecs } = error as { retryAfterSecs?: number }; if (retryAfterSecs != null && retryAfterSecs > 0) { dispatch({ type: SET_STEP_ERROR, diff --git a/ts/types/Calling.std.ts b/ts/types/Calling.std.ts index accab3c14a..7a2a79df1d 100644 --- a/ts/types/Calling.std.ts +++ b/ts/types/Calling.std.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReadonlyDeep } from 'type-fest'; -import type { AudioDevice, Reaction as CallReaction } from '@lockdown-systems/ringrtc'; +import type { + AudioDevice, + Reaction as CallReaction, +} from '@lockdown-systems/ringrtc'; import type { ConversationType } from '../state/ducks/conversations.preload.js'; import type { AciString, ServiceIdString } from './ServiceId.std.js'; import type { CallLinkConversationType } from './CallLink.std.js'; diff --git a/ts/util/callDisposition.preload.ts b/ts/util/callDisposition.preload.ts index 44061c1e87..73603efb64 100644 --- a/ts/util/callDisposition.preload.ts +++ b/ts/util/callDisposition.preload.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import Long from 'long'; -import type { Call, PeekInfo, LocalDeviceState } from '@lockdown-systems/ringrtc'; +import type { + Call, + PeekInfo, + LocalDeviceState, +} from '@lockdown-systems/ringrtc'; import { CallState, ConnectionState, diff --git a/ts/util/desktopCapturer.preload.ts b/ts/util/desktopCapturer.preload.ts index a59e95bf44..af70982831 100644 --- a/ts/util/desktopCapturer.preload.ts +++ b/ts/util/desktopCapturer.preload.ts @@ -258,7 +258,8 @@ export class DesktopCapturer { // process.dlopen() for the addon takes roughly 34ms so avoid running it // until requested by user. - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + /* eslint-disable-next-line global-require, @typescript-eslint/no-var-requires, + import/no-extraneous-dependencies */ const macScreenShare = require('@indutny/mac-screen-share'); const stream: Stream = new macScreenShare.Stream({ width: REQUESTED_SCREEN_SHARE_WIDTH, @@ -371,6 +372,7 @@ function isScreenSource(source: DesktopCapturerSource): boolean { export function isNativeMacScreenShareSupported(): boolean { // process.dlopen() for the addon takes roughly 34ms so avoid running it // until requested by user. - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + /* eslint-disable-next-line global-require, @typescript-eslint/no-var-requires, + import/no-extraneous-dependencies */ return require('@indutny/mac-screen-share').isSupported; } diff --git a/ts/util/lint/license_comments.node.ts b/ts/util/lint/license_comments.node.ts index 61c139faf0..ef334a4625 100644 --- a/ts/util/lint/license_comments.node.ts +++ b/ts/util/lint/license_comments.node.ts @@ -50,6 +50,14 @@ const FILES_TO_IGNORE = new Set( 'js/WebAudioRecorderMp3.js', 'sticker-creator/src/util/protos.d.ts', 'sticker-creator/src/util/protos.js', + // Observer Vault specific files with Lockdown Systems LLC copyright + '.github/workflows/linux-release.yml', + 'docs/UPSTREAM-MERGE-GUIDE.md', + 'scripts/audit-customizations.sh', + 'scripts/fix-ringrtc-imports.sh', + 'scripts/merge-upstream.sh', + 'scripts/release-linux.sh', + 'scripts/release-macos.sh', ].map( // This makes sure the files are correct on Windows. path.normalize