Improve attachment memory safety, downloads, and high-memory alert UX.
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
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:
+37
@@ -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();
|
||||
});
|
||||
});
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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();
|
||||
});
|
||||
});
|
||||
+97
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -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();
|
||||
}
|
||||
|
||||
+58
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+37
-5
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+202
-11
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+153
-80
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user