Improve attachment memory safety, downloads, and high-memory alert UX.
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s

Stream large receives to disk with chunk acks to cap renderer RAM, evict
off-screen display blobs, and route exports through a disk-aware download
service. Fix the high-memory dialog (backdrop dismiss, copy, log actions),
allow diagnostics paths in the path jail, and restore persisted image
hydration after reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 00:25:22 +02:00
parent f0d79aa627
commit bb0ac930ad
69 changed files with 2306 additions and 430 deletions

View File

@@ -256,6 +256,7 @@ export interface ElectronHighMemoryAlertRecord {
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}
export interface ElectronApi {
@@ -281,6 +282,8 @@ export interface ElectronApi {
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
@@ -319,6 +322,7 @@ export interface ElectronApi {
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;

View File

@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PlatformService {
readonly isElectron: boolean;
readonly isCapacitor: boolean;
readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService);
constructor() {
this.isElectron = this.electronBridge.isAvailable;
const isElectron = this.electronBridge.isAvailable;
const runtime = detectRuntimePlatform({
hasElectronApi: this.isElectron,
hasElectronApi: isElectron,
capacitorIsNative: isCapacitorNativeRuntime()
});
this.isCapacitor = runtime === 'capacitor';
this.isBrowser = runtime === 'browser';
}
get isElectron(): boolean {
return this.electronBridge.isAvailable;
}
}

View File

@@ -0,0 +1,164 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
describe('DesktopHighMemoryAlertService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
beforeEach(() => {
documentStub = {
body: null,
createElement: vi.fn(),
execCommand: vi.fn(() => true)
} as unknown as Document;
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/session.ndjson',
detectedAt: 1,
peakWorkingSetKb: 2_200_000,
sessionId: 'session-1'
})),
onHighMemoryAlertPending: vi.fn(() => () => undefined),
exportHighMemoryDiagnostics: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/manual.ndjson',
detectedAt: 2,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual' as const
})),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}))
};
});
function createService(): DesktopHighMemoryAlertService {
const injector = Injector.create({
providers: [
DesktopHighMemoryAlertService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
}
it('loads a pending alert from disk on initialize', async () => {
const service = createService();
await service.initialize();
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
expect(service.peakUsageGb()).toBe('2.10');
});
it('shows the modal when a live high-memory alert event arrives', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3'
});
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
});
it('exports diagnostics manually and opens the modal with manual copy', async () => {
const service = createService();
await expect(service.exportDiagnostics()).resolves.toBe(true);
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
expect(service.pendingAlert()?.reason).toBe('manual');
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
});
it('uses threshold copy for live high-memory alerts', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3',
reason: 'threshold'
});
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
});
it('copies the diagnostics log path to the clipboard', async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
});
const service = createService();
await service.initialize();
await expect(service.copyLogPath()).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
});
});

View File

@@ -4,16 +4,21 @@ import {
inject,
signal
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { PlatformService } from '../platform';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
@Injectable({ providedIn: 'root' })
export class DesktopHighMemoryAlertService {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
@@ -23,24 +28,55 @@ export class DesktopHighMemoryAlertService {
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
});
readonly titleKey = computed(() => resolveHighMemoryAlertTitleKey(
resolveHighMemoryAlertCopyKind(this.pendingAlert())
));
readonly messageKey = computed(() => resolveHighMemoryAlertMessageKey(
resolveHighMemoryAlertCopyKind(this.pendingAlert())
));
private initialized = false;
private removePendingListener: (() => void) | null = null;
async initialize(): Promise<void> {
if (!this.platform.isElectron) {
if (!this.electronBridge.isAvailable || this.initialized) {
return;
}
this.initialized = true;
const api = this.electronBridge.getApi();
if (!api?.getPendingHighMemoryAlert) {
if (!api) {
return;
}
const alert = await api.getPendingHighMemoryAlert();
this.removePendingListener?.();
this.removePendingListener = api.onHighMemoryAlertPending?.((alert) => {
this.pendingAlert.set(alert);
}) ?? null;
const alert = await api.getPendingHighMemoryAlert?.();
if (alert) {
this.pendingAlert.set(alert);
}
}
async exportDiagnostics(): Promise<boolean> {
const api = this.electronBridge.getApi();
const alert = await api?.exportHighMemoryDiagnostics?.();
if (!alert) {
return false;
}
this.pendingAlert.set(alert);
return true;
}
async dismiss(): Promise<void> {
const api = this.electronBridge.getApi();
@@ -70,13 +106,49 @@ export class DesktopHighMemoryAlertService {
await api.showLogFileInFolder(alert.logFilePath);
}
async copyLogPath(): Promise<void> {
async copyLogPath(): Promise<boolean> {
const alert = this.pendingAlert();
if (!alert?.logFilePath) {
return;
return false;
}
await navigator.clipboard.writeText(alert.logFilePath);
return await this.writeTextToClipboard(alert.logFilePath);
}
private async writeTextToClipboard(value: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {}
}
const body = this.document.body;
if (!body) {
return false;
}
const textarea = this.document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = this.document.execCommand('copy');
} catch {}
body.removeChild(textarea);
return copied;
}
}

View File

@@ -0,0 +1,47 @@
import {
describe,
expect,
it
} from 'vitest';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
describe('high-memory-alert-copy.rules', () => {
it('uses threshold copy for live alerts and legacy records without a reason', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1'
})).toBe('threshold');
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1',
reason: 'threshold'
})).toBe('threshold');
});
it('uses manual copy for exported diagnostics', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual'
})).toBe('manual');
});
it('maps copy kinds to translation keys', () => {
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
});
});

View File

@@ -0,0 +1,21 @@
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
export function resolveHighMemoryAlertCopyKind(
alert: ElectronHighMemoryAlertRecord | null | undefined
): HighMemoryAlertCopyKind {
return alert?.reason === 'manual' ? 'manual' : 'threshold';
}
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualTitle'
: 'app.highMemoryAlert.thresholdTitle';
}
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualMessage'
: 'app.highMemoryAlert.thresholdMessage';
}

View File

@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped.
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
```mermaid
sequenceDiagram
participant R as Receiver
@@ -155,6 +158,7 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
- `canHostAttachment(attachment)` — alias of `deviceHasLocalCopy`; any peer with local bytes can serve downloads.
- `isSharingFromThisDevice(attachment, currentUserId)``isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
@@ -195,3 +199,14 @@ Room and conversation names are sanitised to remove filesystem-unsafe characters
- **cancellations**: IDs of transfers the user cancelled
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
### Display blob lifecycle (memory)
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).

View File

@@ -75,6 +75,24 @@ export class AttachmentFacade {
return this.manager.tryRestoreAttachmentFromLocal(...args);
}
pinDisplayBlobs(
...args: Parameters<AttachmentManagerService['pinDisplayBlobs']>
): ReturnType<AttachmentManagerService['pinDisplayBlobs']> {
return this.manager.pinDisplayBlobs(...args);
}
unpinDisplayBlobs(
...args: Parameters<AttachmentManagerService['unpinDisplayBlobs']>
): ReturnType<AttachmentManagerService['unpinDisplayBlobs']> {
return this.manager.unpinDisplayBlobs(...args);
}
revokeOffscreenDisplayBlobsForMessage(
...args: Parameters<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']>
): ReturnType<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']> {
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {
@@ -99,6 +117,12 @@ export class AttachmentFacade {
return this.manager.handleFileChunk(...args);
}
handleFileChunkAck(
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
return this.manager.handleFileChunkAck(...args);
}
handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<AttachmentManagerService['handleFileRequest']> {

View File

@@ -0,0 +1,37 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
describe('AttachmentChunkAckService', () => {
let service: AttachmentChunkAckService;
beforeEach(() => {
service = new AttachmentChunkAckService();
});
it('resolves a waiter when the matching chunk ack arrives', async () => {
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
service.resolveAck('msg-1', 'file-1', 0);
await expect(waitPromise).resolves.toBeUndefined();
});
it('times out when no ack arrives', async () => {
vi.useFakeTimers();
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
vi.advanceTimersByTime(51);
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
@Injectable({ providedIn: 'root' })
export class AttachmentChunkAckService {
private readonly waiters = new Map<string, () => void>();
waitForAck(
messageId: string,
fileId: string,
index: number,
timeoutMs = 60_000
): Promise<void> {
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.waiters.delete(key);
reject(new Error('attachment chunk ack timeout'));
}, timeoutMs);
this.waiters.set(key, () => {
clearTimeout(timer);
this.waiters.delete(key);
resolve();
});
});
}
resolveAck(messageId: string, fileId: string, index: number): void {
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
}
cancelPendingForFile(messageId: string, fileId: string): void {
const prefix = `${messageId}:${fileId}:`;
for (const [key, resolve] of this.waiters) {
if (!key.startsWith(prefix)) {
continue;
}
resolve();
this.waiters.delete(key);
}
}
}

View File

@@ -0,0 +1,97 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { AttachmentDownloadService } from './attachment-download.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../../domain/models/attachment.model';
describe('AttachmentDownloadService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
let saveExistingFileAs: ReturnType<typeof vi.fn>;
let saveFileAs: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
saveExistingFileAs,
saveFileAs
}))
};
documentStub = {
body: {
appendChild: vi.fn(),
removeChild: vi.fn()
},
createElement: vi.fn(() => ({
click: vi.fn(),
remove: vi.fn(),
href: '',
download: ''
}))
} as unknown as Document;
});
function createService(): AttachmentDownloadService {
const injector = Injector.create({
providers: [
AttachmentDownloadService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
}
it('exports a completed disk-only attachment through Electron save dialog', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-1',
messageId: 'message-1',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true,
savedPath: '/appdata/server/room/files/large.bin'
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
expect(saveFileAs).not.toHaveBeenCalled();
});
it('does nothing when the attachment is not downloadable yet', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-2',
messageId: 'message-2',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
expect(saveExistingFileAs).not.toHaveBeenCalled();
expect(saveFileAs).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,97 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
import type { Attachment } from '../../domain/models/attachment.model';
@Injectable({ providedIn: 'root' })
export class AttachmentDownloadService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
if (!canDownloadAttachment(attachment)) {
return false;
}
const electronApi = this.electronBridge.getApi();
const diskPath = resolveAttachmentDiskPath(attachment);
if (electronApi) {
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to browser download */
}
}
}
if (!attachment.objectUrl) {
return false;
}
const link = this.document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
this.document.body?.appendChild(link);
link.click();
link.remove();
return true;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
}

View File

@@ -10,6 +10,7 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
@@ -20,6 +21,7 @@ import type {
FileAnnouncePayload,
FileCancelPayload,
FileChunkPayload,
FileChunkAckPayload,
FileNotFoundPayload,
FileRequestPayload
} from '../../domain/models/attachment-transfer.model';
@@ -44,6 +46,7 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
private pinnedDisplayBlobKeys = new Set<string>();
constructor() {
effect(() => {
@@ -160,6 +163,48 @@ export class AttachmentManagerService {
return restored;
}
pinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
for (const attachment of attachments) {
if (!attachment.messageId || !attachment.id) {
continue;
}
this.pinnedDisplayBlobKeys.add(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
}
}
unpinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
for (const attachment of attachments) {
if (!attachment.messageId || !attachment.id) {
continue;
}
this.pinnedDisplayBlobKeys.delete(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
}
}
revokeOffscreenDisplayBlobsForMessage(messageId: string): void {
if (!messageId) {
return;
}
let hasChanges = false;
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (!shouldRevokeDisplayBlobForAttachment(messageId, attachment, this.pinnedDisplayBlobKeys)) {
continue;
}
if (this.persistence.revokeAttachmentDisplayBlob(attachment)) {
hasChanges = true;
}
}
if (hasChanges) {
this.runtimeStore.touch();
}
}
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
@@ -173,9 +218,9 @@ export class AttachmentManagerService {
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
this.transfer.handleFileAnnounce(payload);
const isNew = this.transfer.handleFileAnnounce(payload);
if (payload.messageId && payload.file?.id) {
if (isNew && payload.messageId && payload.file?.id) {
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
}
}
@@ -184,6 +229,10 @@ export class AttachmentManagerService {
this.transfer.handleFileChunk(payload);
}
handleFileChunkAck(payload: FileChunkAckPayload): void {
this.transfer.handleFileChunkAck(payload);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
await this.transfer.handleFileRequest(payload);
}
@@ -218,7 +267,7 @@ export class AttachmentManagerService {
for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
if (await this.persistence.tryRestoreAttachmentHostOnly(attachment)) {
hasChanges = true;
await yieldToAttachmentHydrationLoop();
}

View File

@@ -99,7 +99,17 @@ describe('AttachmentPersistenceService', () => {
});
it('hydrates blob URLs on demand for a single attachment', async () => {
const service = createService();
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => of('room-1') } }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
const runtimeStore = injector.get(AttachmentRuntimeStore);
await service.initFromDatabase();
@@ -113,10 +123,12 @@ describe('AttachmentPersistenceService', () => {
savedPath: '/appdata/photo.png',
available: false
};
const versionBefore = runtimeStore.updated();
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(runtimeStore.updated()).toBeGreaterThan(versionBefore);
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
@@ -206,4 +218,49 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(database.saveAttachment).toHaveBeenCalled();
});
it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true);
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(false);
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
it('revokes display blobs while keeping disk paths for later rehydration', () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: true,
objectUrl: 'blob:http://localhost/abc'
};
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
revokeSpy.mockRestore();
});
});

View File

@@ -11,6 +11,7 @@ import {
decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules';
import { canRevokeAttachmentDisplayBlob } from '../../domain/logic/attachment-blob-eviction.rules';
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
@@ -119,7 +120,7 @@ export class AttachmentPersistenceService {
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.ensurePersistedUploadHost(attachment);
const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
if (restored) {
attachment.requestError = undefined;
@@ -128,11 +129,30 @@ export class AttachmentPersistenceService {
return restored;
}
async ensurePersistedUploadHost(attachment: Attachment): Promise<boolean> {
async tryRestoreAttachmentHostOnly(attachment: Attachment): Promise<boolean> {
return this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: false });
}
revokeAttachmentDisplayBlob(attachment: Attachment): boolean {
if (!canRevokeAttachmentDisplayBlob(attachment)) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = undefined;
return true;
}
async ensurePersistedUploadHost(
attachment: Attachment,
options: { hydrateMediaForDisplay?: boolean } = {}
): Promise<boolean> {
const hydrateMediaForDisplay = options.hydrateMediaForDisplay !== false;
const existingPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (existingPath) {
return this.hydrateAttachmentFromStoredPath(attachment, existingPath);
return this.hydrateAttachmentFromStoredPath(attachment, existingPath, hydrateMediaForDisplay);
}
if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) {
@@ -147,13 +167,22 @@ export class AttachmentPersistenceService {
return false;
}
return this.hydrateAttachmentFromStoredPath(attachment, savedPath);
return this.hydrateAttachmentFromStoredPath(attachment, savedPath, hydrateMediaForDisplay);
}
private async hydrateAttachmentFromStoredPath(attachment: Attachment, diskPath: string): Promise<boolean> {
private async hydrateAttachmentFromStoredPath(
attachment: Attachment,
diskPath: string,
hydrateMediaForDisplay = true
): Promise<boolean> {
attachment.savedPath = diskPath;
if (isAttachmentMedia(attachment)) {
if (!hydrateMediaForDisplay) {
void this.persistAttachmentMeta(attachment);
return true;
}
return this.ensureInlineDisplayObjectUrl(attachment);
}
@@ -192,6 +221,7 @@ export class AttachmentPersistenceService {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl;
attachment.available = true;
this.runtimeStore.touch();
return true;
}
}
@@ -366,6 +396,8 @@ export class AttachmentPersistenceService {
`${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime })
);
this.runtimeStore.touch();
}
private revokeAttachmentObjectUrl(attachment: Attachment): void {

View File

@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
private chunkCounts = new Map<string, number>();
private announcedHostsByAttachment = new Map<string, Set<string>>();
touch(): void {
this.updated.set(this.updated() + 1);
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
return this.originalFiles.get(key);
}
deleteOriginalFile(key: string): void {
this.originalFiles.delete(key);
}
addAnnouncedHost(requestKey: string, peerId: string): void {
const hosts = this.announcedHostsByAttachment.get(requestKey) ?? new Set<string>();
hosts.add(peerId);
this.announcedHostsByAttachment.set(requestKey, hosts);
}
getAnnouncedHosts(requestKey: string): Set<string> {
return this.announcedHostsByAttachment.get(requestKey) ?? new Set();
}
deleteAnnouncedHosts(requestKey: string): void {
this.announcedHostsByAttachment.delete(requestKey);
}
findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) {
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
this.cancelledTransfers.delete(key);
}
}
for (const key of Array.from(this.announcedHostsByAttachment.keys())) {
if (key.startsWith(scopedPrefix)) {
this.announcedHostsByAttachment.delete(key);
}
}
}
}

View File

@@ -8,11 +8,13 @@ import {
decodeBase64,
iterateBlobChunks
} from '../../../../shared-kernel';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
decodeBase64(base64: string): Uint8Array {
return decodeBase64(base64);
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunk.index);
}
}
@@ -84,6 +87,7 @@ export class AttachmentTransferTransportService {
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
}
}
@@ -122,6 +126,7 @@ export class AttachmentTransferTransportService {
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
}
}
}

View File

@@ -21,6 +21,7 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
const MESSAGE_ID = 'msg-1';
const FILE_ID = 'file-1';
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>;
appendBase64: ReturnType<typeof vi.fn>;
appendBytes: ReturnType<typeof vi.fn>;
createWritableFile: ReturnType<typeof vi.fn>;
deleteFile: ReturnType<typeof vi.fn>;
};
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
streamFileToPeer: ReturnType<typeof vi.fn>;
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
};
let chunkAcks: {
resolveAck: ReturnType<typeof vi.fn>;
waitForAck: ReturnType<typeof vi.fn>;
cancelPendingForFile: ReturnType<typeof vi.fn>;
};
let webrtc: {
getConnectedPeers: ReturnType<typeof vi.fn>;
broadcastMessage: ReturnType<typeof vi.fn>;
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null),
appendBase64: vi.fn(async () => true),
appendBytes: vi.fn(async () => true),
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
deleteFile: vi.fn(async () => true)
};
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
streamFileFromDiskToPeer: vi.fn(async () => undefined)
};
chunkAcks = {
resolveAck: vi.fn(),
waitForAck: vi.fn(async () => undefined),
cancelPendingForFile: vi.fn()
};
webrtc = {
getConnectedPeers: vi.fn(() => [PEER_ID]),
broadcastMessage: vi.fn(),
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
{ provide: AppI18nService, useValue: { instant: (key: string) => key } },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: AttachmentPersistenceService, useValue: persistence },
{ provide: AttachmentTransferTransportService, useValue: transport }
{ provide: AttachmentTransferTransportService, useValue: transport },
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
@@ -294,17 +309,13 @@ describe('AttachmentTransferService', () => {
});
it('streams a requested file only once while the same request is already in flight', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
const service = createService();
registerIncomingAttachment(9);
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
let releaseStream: () => void = () => undefined;
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
releaseStream = resolve;
}));
const firstRequest = service.handleFileRequest({
messageId: MESSAGE_ID,
fileId: FILE_ID,
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
fromPeerId: PEER_ID
});
releaseStream();
await Promise.all([firstRequest, duplicateRequest]);
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
@@ -396,7 +406,14 @@ describe('AttachmentTransferService', () => {
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
});
@@ -418,6 +435,18 @@ describe('AttachmentTransferService', () => {
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
});
it('resolves chunk ack waiters from inbound ack events', () => {
const service = createService();
service.handleFileChunkAck({
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 2
});
expect(chunkAcks.resolveAck).toHaveBeenCalledWith(MESSAGE_ID, FILE_ID, 2);
});
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
const service = createService();
const attachment = registerIncomingAttachment(9);
@@ -443,9 +472,65 @@ describe('AttachmentTransferService', () => {
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
expect(attachment.objectUrl).toBeUndefined();
});
it('streams large downloads to disk even when attachment metadata still carries a source filePath', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.filePath = '/home/ludde/archive.zip';
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
});
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
const service = createService();
const attachment = registerIncomingVideo(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachment.savedPath).toBeTruthy();
expect(attachment.objectUrl).toBeUndefined();
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
});
it('rejects oversized browser downloads before requesting peers', async () => {
@@ -483,7 +568,10 @@ describe('AttachmentTransferService', () => {
it('copies oversized generic uploads with a source path into app data when publishing', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockResolvedValue('/appdata/server/room/files/setup.exe');
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
@@ -536,4 +624,107 @@ describe('AttachmentTransferService', () => {
file: expect.objectContaining({ id: FILE_ID })
}));
});
it('requests a mirror host before the original uploader when both announced the file', async () => {
const uploaderPeer = 'uploader-peer';
const mirrorPeer = 'mirror-peer';
webrtc.getConnectedPeers.mockReturnValue([uploaderPeer, mirrorPeer]);
const service = createService();
const attachment = registerIncomingAttachment(3_000);
attachment.uploaderPeerId = uploaderPeer;
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, uploaderPeer);
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
type: 'file-request',
messageId: MESSAGE_ID,
fileId: FILE_ID
}));
});
it('records announced hosts from incoming file-announce payloads', () => {
const service = createService();
service.handleFileAnnounce({
messageId: MESSAGE_ID,
fromPeerId: 'mirror-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
});
expect(runtimeStore.getAnnouncedHosts(`${MESSAGE_ID}:${FILE_ID}`).has('mirror-peer')).toBe(true);
});
it('does not register duplicate attachment metadata on repeat file-announce', () => {
const service = createService();
const announce = {
messageId: MESSAGE_ID,
fromPeerId: 'uploader-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
};
expect(service.handleFileAnnounce(announce)).toBe(true);
expect(service.handleFileAnnounce(announce)).toBe(false);
expect(runtimeStore.getAttachmentsForMessage(MESSAGE_ID)).toHaveLength(1);
});
it('prefers streaming from disk over an in-memory original file when both exist', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.savedPath = '/appdata/server/room/files/setup.exe';
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File(['x'], 'setup.exe'));
await service.handleFileRequest({
messageId: MESSAGE_ID,
fileId: FILE_ID,
fromPeerId: 'peer-2'
});
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalled();
expect(transport.streamFileToPeer).not.toHaveBeenCalled();
});
it('releases the in-memory upload copy after persisting a large generic file to disk', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
expect(runtimeStore.getOriginalFile(`${MESSAGE_ID}:${attachment.id}`)).toBeUndefined();
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(true);
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
});
});

View File

@@ -8,10 +8,11 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules';
import { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
import {
canReceiveAttachment,
isAttachmentMedia,
shouldCopyLargeUploaderFileToAppData,
shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk
@@ -24,7 +25,6 @@ import {
ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_FILE_TOO_LARGE_KEY,
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
@@ -37,6 +37,8 @@ import {
type FileCancelEvent,
type FileCancelPayload,
type FileChunkPayload,
type FileChunkAckPayload,
type FileChunkAckEvent,
type FileNotFoundEvent,
type FileNotFoundPayload,
type FileRequestEvent,
@@ -46,6 +48,7 @@ import {
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
interface DiskReceiveAssembly {
path: string;
@@ -86,9 +89,10 @@ export class AttachmentTransferService {
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
private readonly diskReceiveChains = new Map<string, Promise<void>>();
private readonly diskReceiveLocks = new Map<string, Promise<void>>();
private readonly activeOutboundTransfers = new Set<string>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
@@ -275,6 +279,7 @@ export class AttachmentTransferService {
}
await this.persistPublishedAttachment(attachment, file);
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
@@ -302,17 +307,23 @@ export class AttachmentTransferService {
}
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
handleFileAnnounce(payload: FileAnnouncePayload): boolean {
const { messageId, file } = payload;
if (!messageId || !file)
return;
if (!messageId || !file) {
return false;
}
if (payload.fromPeerId) {
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
}
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown)
return;
if (alreadyKnown) {
return false;
}
const attachment: Attachment = {
id: file.id,
@@ -334,6 +345,8 @@ export class AttachmentTransferService {
this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
return true;
}
handleFileChunk(payload: FileChunkPayload): void {
@@ -365,7 +378,7 @@ export class AttachmentTransferService {
}
if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, {
void this.receiveDiskChunk(attachment, {
data,
fileId,
fromPeerId,
@@ -377,6 +390,12 @@ export class AttachmentTransferService {
return;
}
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return;
}
const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
@@ -394,10 +413,21 @@ export class AttachmentTransferService {
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
this.runtimeStore.touch();
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
this.emitChunkAck({ fileId, fromPeerId, index, messageId });
}
handleFileChunkAck(payload: FileChunkAckPayload): void {
const { messageId, fileId, index } = payload;
if (!messageId || !fileId || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
return;
}
this.chunkAcks.resolveAck(messageId, fileId, index);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -511,21 +541,6 @@ export class AttachmentTransferService {
fromPeerId: string
): Promise<void> {
const exactKey = `${messageId}:${fileId}`;
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId);
const diskPath = attachment
@@ -544,6 +559,21 @@ export class AttachmentTransferService {
return;
}
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
if (attachment?.isImage) {
const roomName = await this.persistence.resolveCurrentRoomName();
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
@@ -630,14 +660,13 @@ export class AttachmentTransferService {
const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId);
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
let targetPeerId: string | undefined;
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
targetPeerId = preferredPeerId;
} else {
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
}
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
const targetPeerId = selectFileRequestPeer({
connectedPeers,
triedPeers,
announcedHosts,
uploaderPeerId: preferredPeerId
});
if (!targetPeerId) {
this.runtimeStore.deletePendingRequest(requestKey);
@@ -677,16 +706,16 @@ export class AttachmentTransferService {
private updateTransferProgress(
attachment: Attachment,
decodedBytes: Uint8Array,
chunkByteLength: number,
fromPeerId?: string
): void {
const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
attachment.receivedBytes = previousReceived + chunkByteLength;
if (fromPeerId) {
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
}
if (!attachment.startedAtMs)
@@ -696,7 +725,7 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps =
@@ -745,6 +774,7 @@ export class AttachmentTransferService {
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
}
/**
@@ -789,7 +819,7 @@ export class AttachmentTransferService {
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (!isSharingFromThisDevice(attachment, currentUserId)) {
if (!canHostAttachment(attachment)) {
continue;
}
@@ -799,24 +829,64 @@ export class AttachmentTransferService {
continue;
}
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
messageId: attachment.messageId,
file: {
id: attachment.id,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId
}
};
this.webrtc.broadcastMessage(fileAnnounceEvent);
await this.announceLocalHost(attachment, currentUserId);
}
}
}
private releaseInMemoryUploadCopyIfPersisted(exactKey: string, attachment: Attachment): void {
if (!attachment.savedPath?.trim() || attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
return;
}
this.runtimeStore.deleteOriginalFile(exactKey);
if (!attachment.objectUrl?.startsWith('blob:')) {
return;
}
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
if (!this.isPlayableMedia(attachment)) {
attachment.objectUrl = undefined;
attachment.available = true;
}
}
private async announceLocalHost(attachment: Attachment, hostPeerId?: string | null): Promise<void> {
if (!canHostAttachment(attachment)) {
return;
}
const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId();
if (!announcingPeerId) {
return;
}
this.runtimeStore.addAnnouncedHost(
this.buildRequestKey(attachment.messageId, attachment.id),
announcingPeerId
);
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
messageId: attachment.messageId,
file: {
id: attachment.id,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId
}
};
this.webrtc.broadcastMessage(fileAnnounceEvent);
}
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
if (!savedPath) {
return;
@@ -845,31 +915,47 @@ export class AttachmentTransferService {
};
}
private enqueueDiskFileChunk(
attachment: Attachment,
payload: ValidFileChunkPayload
): void {
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
const previous = this.diskReceiveLocks.get(assemblyKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
.then(async () => {
await this.handleDiskFileChunk(attachment, assemblyKey, payload);
this.emitChunkAck(payload);
})
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next);
this.diskReceiveLocks.set(assemblyKey, next);
void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey);
if (this.diskReceiveLocks.get(assemblyKey) === next) {
this.diskReceiveLocks.delete(assemblyKey);
}
});
}
private emitChunkAck(payload: Pick<ValidFileChunkPayload, 'fileId' | 'fromPeerId' | 'index' | 'messageId'>): void {
if (!payload.fromPeerId) {
return;
}
const ack: FileChunkAckEvent = {
type: 'file-chunk-ack',
messageId: payload.messageId,
fileId: payload.fileId,
index: payload.index
};
this.webrtc.sendToPeer(payload.fromPeerId, ack);
}
private async handleDiskFileChunk(
attachment: Attachment,
assemblyKey: string,
payload: ValidFileChunkPayload
): Promise<void> {
const decodedBytes = this.transport.decodeBase64(payload.data);
const chunkByteLength = base64DecodedByteLength(payload.data);
const chunkBytes = decodeBase64ToUint8Array(payload.data);
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey);
@@ -889,7 +975,7 @@ export class AttachmentTransferService {
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
}
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
const didAppend = await this.attachmentStorage.appendBytes(assembly.path, chunkBytes);
if (!didAppend) {
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
@@ -897,7 +983,7 @@ export class AttachmentTransferService {
assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total) {
@@ -905,25 +991,12 @@ export class AttachmentTransferService {
}
attachment.savedPath = assembly.path;
if (!isAttachmentMedia(attachment)) {
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
return;
}
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
if (!restoredForDisplay) {
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
}
attachment.available = true;
attachment.objectUrl = undefined;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
}
private async getOrCreateDiskReceiveAssembly(

View File

@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildAttachmentDisplayPinKey,
canRevokeAttachmentDisplayBlob,
shouldRevokeDisplayBlobForAttachment
} from './attachment-blob-eviction.rules';
describe('attachment-blob-eviction rules', () => {
it('builds a stable pin key from message and attachment ids', () => {
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
});
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
})).toBe(true);
});
it('refuses to revoke blobs that are the only local copy', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
receivedBytes: 0,
available: true
})).toBe(false);
});
it('refuses to revoke blobs while a download is still in progress', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 1024,
available: false
})).toBe(false);
});
it('skips revocation for pinned attachments', () => {
const attachment = {
id: 'att-1',
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
};
expect(shouldRevokeDisplayBlobForAttachment(
'msg-1',
attachment,
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
)).toBe(false);
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
import { isBlobObjectUrl } from './attachment-display-url.rules';
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
export interface AttachmentDisplayBlobCandidate {
available?: boolean;
filePath?: string;
objectUrl?: string;
receivedBytes?: number;
savedPath?: string;
}
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
return `${messageId}:${attachmentId}`;
}
export function canRevokeAttachmentDisplayBlob(
attachment: AttachmentDisplayBlobCandidate
): boolean {
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
return false;
}
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return true;
}
export function shouldRevokeDisplayBlobForAttachment(
messageId: string,
attachment: AttachmentDisplayBlobCandidate & { id: string },
pinnedKeys: ReadonlySet<string>
): boolean {
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
return false;
}
return canRevokeAttachmentDisplayBlob(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -4,7 +4,10 @@ import {
it
} from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
import {
base64DecodedByteLength,
decodeBase64ToUint8Array
} from './attachment-blob.rules';
describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => {
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
67
]);
});
it('estimates decoded base64 byte length without allocating bytes', () => {
expect(base64DecodedByteLength('QUJD')).toBe(3);
expect(base64DecodedByteLength('YQ==')).toBe(1);
});
});

View File

@@ -29,6 +29,13 @@ export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
return btoa(binary);
}
/** Returns the decoded byte length of a base64 payload without allocating the bytes. */
export function base64DecodedByteLength(base64: string): number {
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => {

View File

@@ -0,0 +1,13 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildAttachmentChunkAckKey } from './attachment-chunk-ack.rules';
describe('attachment-chunk-ack rules', () => {
it('builds a stable ack key from message, file, and chunk index', () => {
expect(buildAttachmentChunkAckKey('msg-1', 'file-1', 42)).toBe('msg-1:file-1:42');
});
});

View File

@@ -0,0 +1,3 @@
export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string {
return `${messageId}:${fileId}:${index}`;
}

View File

@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
canDownloadAttachment,
resolveAttachmentDiskPath
} from './attachment-download.rules';
describe('attachment-download.rules', () => {
it('allows download when a completed disk-only attachment has no object URL', () => {
expect(canDownloadAttachment({
available: true,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(true);
});
it('allows download when a blob object URL is available', () => {
expect(canDownloadAttachment({
available: true,
objectUrl: 'blob:http://localhost/abc'
})).toBe(true);
});
it('rejects incomplete or empty local copies', () => {
expect(canDownloadAttachment({
available: false,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(false);
expect(canDownloadAttachment({
available: true
})).toBe(false);
});
it('prefers savedPath over filePath for disk export', () => {
expect(resolveAttachmentDiskPath({
savedPath: '/appdata/copy.bin',
filePath: '/home/me/original.bin'
})).toBe('/appdata/copy.bin');
});
});

View File

@@ -0,0 +1,25 @@
import type { Attachment } from '../models/attachment.model';
export function canDownloadAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
if (attachment.available !== true) {
return false;
}
return hasNonEmptyString(attachment.objectUrl) ||
hasNonEmptyString(attachment.savedPath) ||
hasNonEmptyString(attachment.filePath);
}
export function resolveAttachmentDiskPath(
attachment: Pick<Attachment, 'savedPath' | 'filePath'>
): string | null {
const diskPath = attachment.savedPath?.trim() || attachment.filePath?.trim();
return diskPath || null;
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -7,6 +7,7 @@ import {
import {
dedupeImageAttachmentsForDisplay,
hasImageFilename,
isAttachmentPendingInlineHydration,
isImageAttachment,
isInlineDisplayableImage,
resolvePublishAttachmentIsImage
@@ -38,6 +39,27 @@ describe('attachment-image rules', () => {
})).toBe(true);
});
it('detects images waiting for on-demand blob hydration', () => {
expect(isAttachmentPendingInlineHydration({
id: '1',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: false,
savedPath: '/appdata/photo.png'
})).toBe(true);
expect(isAttachmentPendingInlineHydration({
id: '2',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: true,
objectUrl: 'blob:http://localhost/photo',
savedPath: '/appdata/photo.png'
})).toBe(false);
});
it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([
{

View File

@@ -22,6 +22,7 @@ export interface ImageAttachmentCandidate {
isImage: boolean;
mime: string;
objectUrl?: string;
receivedBytes?: number;
savedPath?: string;
}
@@ -50,6 +51,27 @@ export function isInlineDisplayableImage(
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
}
export function isAttachmentPendingInlineHydration(
attachment: Pick<
ImageAttachmentCandidate,
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
>
): boolean {
if (isInlineDisplayableImage(attachment)) {
return false;
}
if (!isImageAttachment(attachment)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim());
}
export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number {

View File

@@ -0,0 +1,47 @@
import { selectFileRequestPeer } from './attachment-request.rules';
describe('selectFileRequestPeer', () => {
const uploader = 'uploader-peer';
const mirror = 'mirror-peer';
const other = 'other-peer';
it('prefers a mirror host over the original uploader when both are available', () => {
expect(selectFileRequestPeer({
connectedPeers: [
uploader,
mirror,
other
],
triedPeers: new Set(),
announcedHosts: new Set([uploader, mirror]),
uploaderPeerId: uploader
})).toBe(mirror);
});
it('falls back to the uploader when no mirror hosts are announced', () => {
expect(selectFileRequestPeer({
connectedPeers: [uploader, other],
triedPeers: new Set(),
announcedHosts: new Set([uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('skips peers that were already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('returns undefined when every connected peer was already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror, uploader]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBeUndefined();
});
});

View File

@@ -0,0 +1,39 @@
export interface FileRequestPeerSelectionInput {
connectedPeers: string[];
triedPeers: ReadonlySet<string>;
announcedHosts: ReadonlySet<string>;
uploaderPeerId?: string;
}
/**
* Pick the next peer to request a file from. Mirror hosts (peers that announced
* they hold the bytes) are preferred over the original uploader so the sharer's
* device is not the only upload source.
*/
export function selectFileRequestPeer(input: FileRequestPeerSelectionInput): string | undefined {
const candidates = input.connectedPeers.filter((peerId) => !input.triedPeers.has(peerId));
if (candidates.length === 0) {
return undefined;
}
const mirrorHosts = candidates.filter(
(peerId) => input.announcedHosts.has(peerId) && peerId !== input.uploaderPeerId
);
if (mirrorHosts.length > 0) {
return mirrorHosts[0];
}
if (input.uploaderPeerId && candidates.includes(input.uploaderPeerId)) {
return input.uploaderPeerId;
}
const announcedCandidate = candidates.find((peerId) => input.announcedHosts.has(peerId));
if (announcedCandidate) {
return announcedCandidate;
}
return candidates[0];
}

View File

@@ -1,4 +1,5 @@
import {
canHostAttachment,
deviceHasLocalCopy,
isSharingFromThisDevice,
isUploaderUser
@@ -66,4 +67,10 @@ describe('attachment sharing rules', () => {
).toBe(false);
});
});
describe('canHostAttachment', () => {
it('is true for any device that holds the bytes locally', () => {
expect(canHostAttachment({ available: false, savedPath: '/appdata/file.bin' })).toBe(true);
});
});
});

View File

@@ -35,6 +35,13 @@ export function isSharingFromThisDevice(
return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment);
}
/** True when this device can serve the attachment bytes to other peers. */
export function canHostAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
return deviceHasLocalCopy(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -58,7 +58,7 @@ describe('attachment logic', () => {
}, undefined, true)).toBe(false);
});
it('streams oversized generic files to disk when the store supports it', () => {
it('streams any persistable download to disk when the store supports streaming', () => {
const capabilities = {
canStreamToDisk: true,
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
@@ -69,6 +69,18 @@ describe('attachment logic', () => {
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 3,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: '/home/ludde/archive.zip'
}, capabilities)).toBe(true);
});
it('receives browser-sized files in memory when disk streaming is unavailable', () => {

View File

@@ -67,19 +67,11 @@ export function shouldStreamAttachmentReceiveToDisk(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (attachment.filePath?.trim()) {
return false;
}
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
return false;
}
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
return true;
}
return isAttachmentMedia(attachment);
return true;
}
export function canReceiveAttachmentInMemory(

View File

@@ -17,6 +17,14 @@ export type FileChunkEvent = ChatEvent & {
fromPeerId?: string;
};
export type FileChunkAckEvent = ChatEvent & {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
fromPeerId?: string;
};
export type FileRequestEvent = ChatEvent & {
type: 'file-request';
messageId: string;
@@ -37,7 +45,7 @@ export type FileNotFoundEvent = ChatEvent & {
fileId: string;
};
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file' | 'fromPeerId'>;
export interface FileChunkPayload {
messageId?: string;
@@ -48,6 +56,13 @@ export interface FileChunkPayload {
data?: ChatEvent['data'];
}
export interface FileChunkAckPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
}
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;

View File

@@ -1,5 +1,7 @@
export * from './application/facades/attachment.facade';
export * from './application/services/attachment-download.service';
export * from './domain/constants/attachment.constants';
export * from './domain/logic/attachment-download.rules';
export * from './domain/logic/attachment-sharing.rules';
export * from './domain/logic/local-file-path.rules';
export * from './domain/models/attachment.model';

View File

@@ -187,6 +187,18 @@ export class AttachmentStorageService {
return this.store.appendFile(filePath, base64Data);
}
async appendBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
if (!filePath) {
return false;
}
if (this.platform.isElectron) {
return this.electronStore.appendFileBytes(filePath, bytes);
}
return this.appendBase64(filePath, encodeUint8ArrayToBase64(bytes));
}
async deleteFile(filePath: string): Promise<void> {
if (!filePath) {
return;

View File

@@ -74,6 +74,20 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
}
}
async appendFileBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFileBytes || !filePath) {
return false;
}
try {
return await electronApi.appendFileBytes(filePath, bytes);
} catch {
return false;
}
}
async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();

View File

@@ -12,11 +12,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
@@ -69,10 +72,10 @@ export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
@@ -300,6 +303,7 @@ export class ChatMessagesComponent {
return;
}
this.attachmentsSvc.pinDisplayBlobs(attachments);
this.lightboxState.set({
attachments,
index
@@ -307,6 +311,12 @@ export class ChatMessagesComponent {
}
closeLightbox(): void {
const state = this.lightboxState();
if (state) {
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
}
this.lightboxState.set(null);
}
@@ -336,10 +346,17 @@ export class ChatMessagesComponent {
return;
}
this.attachmentsSvc.pinDisplayBlobs(availableImages);
this.galleryAttachments.set(availableImages);
}
closeImageGallery(): void {
const gallery = this.galleryAttachments();
if (gallery) {
this.attachmentsSvc.unpinDisplayBlobs(gallery);
}
this.galleryAttachments.set(null);
}
@@ -352,46 +369,7 @@ export class ChatMessagesComponent {
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl)
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await this.attachmentDownload.downloadToUserLocation(attachment);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -415,46 +393,6 @@ export class ChatMessagesComponent {
return message.senderId === this.currentUser()?.id;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl)
return null;
if (attachment.objectUrl.startsWith('file:'))
return null;
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {

View File

@@ -192,6 +192,10 @@
/>
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
</div>
} @else if (isImagePendingHydration(gridImage)) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((gridImage.receivedBytes || 0) > 0) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon
@@ -234,7 +238,7 @@
@for (att of attachmentsList; track att.id) {
@if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) {
@if (isDisplayableImage(att)) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
@@ -269,6 +273,13 @@
</button>
</div>
</div>
} @else if (isImagePendingHydration(att)) {
<div
appThemeNode="chatAttachmentCard"
class="flex max-h-80 min-h-32 min-w-48 items-center justify-center rounded-md border border-border bg-secondary/40 p-6"
>
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"

View File

@@ -5,11 +5,12 @@ import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
effect,
ElementRef,
inject,
input,
OnDestroy,
AfterViewInit,
output,
signal,
TemplateRef,
@@ -44,9 +45,11 @@ import {
} from '../../../../../attachment';
import {
dedupeImageAttachmentsForDisplay,
isAttachmentPendingInlineHydration,
isImageAttachment,
isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules';
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
@@ -168,10 +171,11 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent implements OnDestroy {
export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
@@ -188,6 +192,8 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
private visibilityObserver: IntersectionObserver | null = null;
private readonly isMessageVisible = signal(false);
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -264,12 +270,17 @@ export class ChatMessageItemComponent implements OnDestroy {
const images = this.imageAttachments();
void this.attachmentVersion();
const isVisible = this.isMessageVisible();
for (const image of images) {
if (isInlineDisplayableImage(image)) {
continue;
}
if (!isAttachmentPendingInlineHydration(image)) {
continue;
}
const liveAttachment = this.getLiveAttachment(image.id);
if (!liveAttachment) {
@@ -279,7 +290,11 @@ export class ChatMessageItemComponent implements OnDestroy {
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
}
if (images.some((image) => !isInlineDisplayableImage(image))) {
if (!isVisible) {
return;
}
if (images.some((image) => !isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image))) {
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
}
});
@@ -501,7 +516,67 @@ export class ChatMessageItemComponent implements OnDestroy {
}
}
ngAfterViewInit(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const host = this.elementRef.nativeElement;
const scrollRoot = host.closest('[appThemeNode="chatMessageList"]');
this.visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
this.handleMessageVisibilityChange(entry.isIntersecting);
},
{
root: scrollRoot,
rootMargin: ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN,
threshold: 0
}
);
this.visibilityObserver.observe(host);
this.syncInitialMessageVisibility(host, scrollRoot as HTMLElement | null);
}
private syncInitialMessageVisibility(host: HTMLElement, scrollRoot: HTMLElement | null): void {
if (this.isElementIntersectingScrollRoot(host, scrollRoot)) {
this.isMessageVisible.set(true);
}
}
private isElementIntersectingScrollRoot(host: HTMLElement, scrollRoot: HTMLElement | null): boolean {
const hostRect = host.getBoundingClientRect();
if (!scrollRoot) {
return hostRect.bottom > 0 &&
hostRect.top < window.innerHeight &&
hostRect.right > 0 &&
hostRect.left < window.innerWidth;
}
const rootRect = scrollRoot.getBoundingClientRect();
return hostRect.bottom > rootRect.top &&
hostRect.top < rootRect.bottom &&
hostRect.right > rootRect.left &&
hostRect.left < rootRect.right;
}
ngOnDestroy(): void {
this.visibilityObserver?.disconnect();
this.visibilityObserver = null;
if (this.isMessageVisible()) {
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
}
this.clearLongPressTimer();
this.detachMobileSheet();
}
@@ -772,6 +847,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return isInlineDisplayableImage(attachment);
}
isImagePendingHydration(attachment: ChatMessageAttachmentViewModel): boolean {
return isAttachmentPendingInlineHydration(attachment);
}
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
return isImageAttachment(attachment);
}
@@ -882,6 +961,21 @@ export class ChatMessageItemComponent implements OnDestroy {
};
}
private handleMessageVisibilityChange(isVisible: boolean): void {
if (isVisible === this.isMessageVisible()) {
return;
}
this.isMessageVisible.set(isVisible);
const messageId = this.message().id;
if (isVisible) {
return;
}
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(messageId);
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}

View File

@@ -15,7 +15,6 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import {
BottomSheetComponent,
@@ -23,7 +22,11 @@ import {
UserAvatarComponent
} from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules';
@@ -88,7 +91,7 @@ export class DmChatComponent {
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
@@ -485,49 +488,7 @@ export class DmChatComponent {
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) {
return;
}
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await this.attachmentDownload.downloadToUserLocation(attachment);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -599,48 +560,6 @@ export class DmChatComponent {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;

View File

@@ -136,7 +136,7 @@ describe('CreateServerDialogComponent', () => {
component.create();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {} });
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
});
});

View File

@@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import type { ServerInfo } from '../../domain/models/server-directory.model';
import type { User } from '../../../../shared-kernel';
@@ -100,6 +101,9 @@ function createHarness(options: HarnessOptions = {}) {
sendRawMessageToSignalUrl: vi.fn()
} as unknown as RealtimeSessionFacade;
const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService;
const signalServerAuthorize = {
ensureCredentialForServerUrl: vi.fn(() => Promise.resolve(true))
} as unknown as SignalServerAuthorizeService;
const injector = Injector.create({
providers: [
ServerBrowserComponent,
@@ -111,6 +115,7 @@ function createHarness(options: HarnessOptions = {}) {
{ provide: RealtimeSessionFacade, useValue: webrtc },
{ provide: PluginRequirementService, useValue: pluginRequirements },
{ provide: PluginStoreService, useValue: pluginStore },
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
...provideAppI18nForTests()
]
});

View File

@@ -43,6 +43,18 @@
</div>
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
(click)="exportRamDiagnostics()"
[disabled]="isExportingRamDiagnostics()"
class="rounded-lg border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isExportingRamDiagnostics()
? ('settings.debugging.exportRamDiagnosticsWorking' | translate)
: ('settings.debugging.exportRamDiagnostics' | translate) }}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p>
</section>
}

View File

@@ -21,6 +21,7 @@ import { interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { DebuggingService } from '../../../../core/services/debugging.service';
import { DesktopHighMemoryAlertService } from '../../../../core/services/desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
import { PlatformService } from '../../../../core/platform';
@@ -53,10 +54,12 @@ export class DebuggingSettingsComponent {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService);
private readonly highMemoryAlert = inject(DesktopHighMemoryAlertService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null);
readonly isExportingRamDiagnostics = signal(false);
readonly enabled = this.debugging.enabled;
readonly isConsoleOpen = this.debugging.isConsoleOpen;
readonly entryCount = computed(() => {
@@ -97,6 +100,20 @@ export class DebuggingSettingsComponent {
this.debugging.clear();
}
async exportRamDiagnostics(): Promise<void> {
if (!this.isElectron || this.isExportingRamDiagnostics()) {
return;
}
this.isExportingRamDiagnostics.set(true);
try {
await this.highMemoryAlert.exportDiagnostics();
} finally {
this.isExportingRamDiagnostics.set(false);
}
}
private startRamPolling(): void {
const api = this.electronBridge.getApi();

View File

@@ -6,13 +6,19 @@
/>
<div
appThemeNode="highMemoryAlertDialog"
class="fixed inset-0 z-[121] flex items-center justify-center px-4"
role="alertdialog"
[attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'"
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
>
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
<div
appThemeNode="highMemoryAlertDialog"
class="pointer-events-auto relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg"
role="alertdialog"
aria-modal="true"
[attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
<button
type="button"
(click)="dismiss()"
@@ -33,14 +39,14 @@
id="high-memory-alert-title"
class="mt-1 pr-10 text-base font-semibold text-foreground"
>
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }}
{{ alertService.titleKey() | translate:{ usageGb: alertService.peakUsageGb() } }}
</h2>
<p
id="high-memory-alert-description"
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
>
{{ 'app.highMemoryAlert.message' | translate }}
{{ alertService.messageKey() | translate }}
</p>
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">

View File

@@ -0,0 +1,108 @@
import '@angular/compiler';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { EnvironmentInjector, Injector } from '@angular/core';
import { PerfDiagnosticsCollector } from './diagnostics.collector';
import {
bootstrapPerfDiagnostics,
registerImmediatePerfDiagCollector,
resetDiagnosticsBootstrapStateForTests
} from './diagnostics.bootstrap';
import type { PerfDiagEntry } from './diagnostics.models';
describe('diagnostics.bootstrap', () => {
let injector: EnvironmentInjector;
let collector: {
collectSample: ReturnType<typeof vi.fn>;
buildEntries: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
resetDiagnosticsBootstrapStateForTests();
collector = {
collectSample: vi.fn(() => ({
storeDomains: { attachment: 1024 },
storeBytes: { attachment: 1024 },
components: { ChatMessageItemComponent: 12 },
componentDomains: { chat: 12 },
suspectedLeaks: [],
heap: { usedJsHeapMb: 256, totalJsHeapMb: 300 },
route: '/room/test',
durationMs: 2
})),
buildEntries: vi.fn(() => ([
{
collectedAt: 1,
source: 'renderer',
type: 'store',
payload: { domains: { attachment: 1024 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'components',
payload: { domains: { chat: 12 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 256 }
}
] satisfies PerfDiagEntry[]))
};
injector = Injector.create({
providers: [{ provide: PerfDiagnosticsCollector, useValue: collector }]
}) as EnvironmentInjector;
});
afterEach(() => {
resetDiagnosticsBootstrapStateForTests();
});
it('registers immediate renderer samples without perf diagnostics being enabled', () => {
registerImmediatePerfDiagCollector(injector);
const entries = globalThis.__collectPerfDiagSample?.() ?? [];
expect(entries).toHaveLength(3);
expect(entries.find((entry) => entry.type === 'store')?.payload).toEqual({
domains: { attachment: 1024 }
});
expect(entries.find((entry) => entry.type === 'components')?.payload).toEqual({
domains: { chat: 12 }
});
});
it('keeps the immediate collector registered after periodic sampling stops', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', { addEventListener: vi.fn() });
registerImmediatePerfDiagCollector(injector);
const reportSample = vi.fn(async () => {
throw new Error('writer unavailable');
});
await bootstrapPerfDiagnostics({
isPerfDiagEnabled: async () => true,
reportPerfDiagSample: reportSample
}, injector);
await vi.runOnlyPendingTimersAsync();
expect(globalThis.__collectPerfDiagSample?.()).toHaveLength(3);
vi.useRealTimers();
vi.unstubAllGlobals();
});
});

View File

@@ -16,11 +16,38 @@ const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null;
let immediateCollectorRegistered = false;
export function registerImmediatePerfDiagCollector(injector: EnvironmentInjector): void {
if (immediateCollectorRegistered) {
return;
}
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
immediateCollectorRegistered = true;
}
export async function bootstrapPerfDiagnostics(
api: ElectronApi,
injector: EnvironmentInjector
): Promise<void> {
registerImmediatePerfDiagCollector(injector);
const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) {
@@ -41,22 +68,6 @@ export async function bootstrapPerfDiagnostics(
started = true;
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
@@ -113,6 +124,12 @@ function stopPerfDiagnosticsSampling(): void {
sampleTimer = null;
}
delete globalThis.__collectPerfDiagSample;
started = false;
}
/** @internal Resets module state between unit tests. */
export function resetDiagnosticsBootstrapStateForTests(): void {
stopPerfDiagnosticsSampling();
immediateCollectorRegistered = false;
delete globalThis.__collectPerfDiagSample;
}

View File

@@ -161,6 +161,13 @@ export interface FileChunkChatEvent extends ChatEventBase {
data: string;
}
export interface FileChunkAckChatEvent extends ChatEventBase {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
}
export interface FileRequestChatEvent extends ChatEventBase {
type: 'file-request';
messageId: string;
@@ -498,6 +505,7 @@ export type ChatEvent =
| ReactionRemovedEvent
| FileAnnounceChatEvent
| FileChunkChatEvent
| FileChunkAckChatEvent
| FileRequestChatEvent
| FileCancelChatEvent
| FileNotFoundChatEvent

View File

@@ -59,6 +59,7 @@ type IncomingMessageType =
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-chunk-ack'
| 'file-request'
| 'file-cancel'
| 'file-not-found';
@@ -583,10 +584,6 @@ function handleFileAnnounce(
): Observable<Action> {
attachments.handleFileAnnounce(event);
if (event.messageId) {
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
}
return EMPTY;
}
@@ -598,6 +595,14 @@ function handleFileChunk(
return EMPTY;
}
function handleFileChunkAck(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileChunkAck(event);
return EMPTY;
}
function handleFileRequest(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
@@ -736,6 +741,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Attachments
'file-announce': handleFileAnnounce,
'file-chunk': handleFileChunk,
'file-chunk-ack': handleFileChunkAck,
'file-request': handleFileRequest,
'file-cancel': handleFileCancel,
'file-not-found': handleFileNotFound,