Fix private calls

This commit is contained in:
2026-05-17 15:14:52 +02:00
parent 0f6cb3ee77
commit e769a6ee4a
71 changed files with 5821 additions and 349 deletions

View File

@@ -76,6 +76,8 @@ graph TD
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
When Electron serves a file from disk, the sender reads one chunk at a time and uses the buffered data-channel send path so large saved media does not get loaded into renderer memory or flood the receiver.
```mermaid
sequenceDiagram
participant S as Sender
@@ -90,12 +92,12 @@ sequenceDiagram
loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer
Note over R: Append to chunk buffer, or append media directly to disk on Electron
Note over R: Update progress + EWMA speed
end
Note over R: All chunks received
Note over R: Reassemble blob
Note over R: Reassemble blob, or open completed Electron media from disk
Note over R: shouldPersistDownloadedAttachment? Save to disk
```
@@ -131,17 +133,27 @@ When the user navigates to a room, the manager watches the route and decides whi
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?}
{appDataPath}/server/{roomName}/{bucket}/{attachmentId}.{ext?}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
Direct-message attachments use the conversation id instead of the server-room path:
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
```
{appDataPath}/direct-messages/{conversationId}/{bucket}/{attachmentId}.{ext?}
```
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On Electron, saved audio/video records are restored as file-backed URLs; other restored files still need their bytes loaded when a `Blob` URL is required. On browser builds, files stay in memory only.
## Runtime store

View File

@@ -70,17 +70,20 @@ export class AttachmentPersistenceService {
} catch { /* persistence is best-effort */ }
}
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<string | null> {
try {
const roomName = await this.resolveCurrentRoomName();
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer);
if (!diskPath)
return;
return null;
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
return diskPath;
} catch { /* disk save is best-effort */ }
return null;
}
async initFromDatabase(): Promise<void> {
@@ -120,6 +123,10 @@ export class AttachmentPersistenceService {
});
}
async resolveStorageContainerName(attachment: Pick<Attachment, 'messageId'>): Promise<string> {
return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName();
}
private async loadFromDatabase(): Promise<void> {
try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
@@ -176,6 +183,11 @@ export class AttachmentPersistenceService {
continue;
if (attachment.savedPath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
hasChanges = true;
continue;
}
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
@@ -186,6 +198,11 @@ export class AttachmentPersistenceService {
}
if (attachment.filePath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
hasChanges = true;
continue;
}
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) {
@@ -222,6 +239,26 @@ export class AttachmentPersistenceService {
);
}
private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise<boolean> {
if (!this.isPlayableMedia(attachment)) {
return false;
}
const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
if (!fileUrl) {
return false;
}
attachment.objectUrl = fileUrl;
attachment.available = true;
return true;
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>();

View File

@@ -49,6 +49,11 @@ export class AttachmentTransferTransportService {
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
if (this.attachmentStorage.canReadFileChunks()) {
await this.streamFileFromDiskChunksToPeer(targetPeerId, messageId, fileId, diskPath, isCancelled);
return;
}
const base64Full = await this.attachmentStorage.readFile(diskPath);
if (!base64Full)
@@ -78,7 +83,45 @@ export class AttachmentTransferTransportService {
data: base64Chunk
};
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
}
}
private async streamFileFromDiskChunksToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
if (fileSize === null)
return;
const totalChunks = Math.ceil(fileSize / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelled())
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileSize, start + FILE_CHUNK_SIZE_BYTES);
const base64Chunk = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (base64Chunk === null)
return;
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64Chunk
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
}
}
}

View File

@@ -28,6 +28,22 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
interface DiskReceiveAssembly {
path: string;
receivedCount: number;
receivedIndexes: Set<number>;
total: number;
}
interface ValidFileChunkPayload {
data: string;
fileId: string;
fromPeerId?: string;
index: number;
messageId: string;
total: number;
}
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly webrtc = inject(RealtimeSessionFacade);
@@ -36,6 +52,9 @@ export class AttachmentTransferService {
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
private readonly diskReceiveChains = new Map<string, Promise<void>>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
@@ -174,10 +193,19 @@ export class AttachmentTransferService {
attachments.push(attachment);
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
try {
attachment.objectUrl = URL.createObjectURL(file);
const fileUrl = attachment.filePath && this.isPlayableMedia(attachment)
? await this.attachmentStorage.getFileUrl(attachment.filePath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
} catch { /* non-critical */ }
} else {
try {
attachment.objectUrl = URL.createObjectURL(file);
attachment.available = true;
} catch { /* non-critical */ }
}
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
@@ -257,6 +285,19 @@ export class AttachmentTransferService {
if (!attachment)
return;
if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, {
data,
fileId,
fromPeerId,
index,
messageId,
total
});
return;
}
const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
@@ -274,7 +315,7 @@ export class AttachmentTransferService {
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.runtimeStore.touch();
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -375,6 +416,7 @@ export class AttachmentTransferService {
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
void this.deleteDiskReceiveAssembly(assemblyKey);
attachment.receivedBytes = 0;
attachment.speedBps = 0;
@@ -533,11 +575,11 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now;
}
private finalizeTransferIfComplete(
private async finalizeTransferIfComplete(
attachment: Attachment,
assemblyKey: string,
total: number
): void {
): Promise<void> {
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
@@ -551,16 +593,167 @@ export class AttachmentTransferService {
const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (shouldPersistDownloadedAttachment(attachment)) {
void this.persistence.saveFileToDisk(attachment, blob);
}
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
if (shouldPersistDownloadedAttachment(attachment)) {
const diskPath = await this.persistence.saveFileToDisk(attachment, blob);
const fileUrl = diskPath && this.isPlayableMedia(attachment)
? await this.attachmentStorage.getFileUrl(diskPath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
attachment.available = true;
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles();
}
private enqueueDiskFileChunk(
attachment: Attachment,
payload: ValidFileChunkPayload
): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next);
void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey);
}
});
}
private async handleDiskFileChunk(
attachment: Attachment,
assemblyKey: string,
payload: ValidFileChunkPayload
): Promise<void> {
const decodedBytes = this.transport.decodeBase64(payload.data);
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey);
this.clearAttachmentRequestError(attachment);
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
if (!assembly) {
throw new Error('Could not prepare media download on disk.');
}
if (assembly.receivedIndexes.has(payload.index)) {
return;
}
if (payload.index !== assembly.receivedCount) {
throw new Error('Received media chunks out of order. Retry the download.');
}
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
if (!didAppend) {
throw new Error('Could not write media download to disk.');
}
assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total && (attachment.receivedBytes ?? 0) < attachment.size) {
return;
}
const fileUrl = await this.attachmentStorage.getFileUrl(assembly.path);
if (!fileUrl) {
throw new Error('Could not open completed media download from disk.');
}
attachment.savedPath = assembly.path;
attachment.objectUrl = fileUrl;
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
private async getOrCreateDiskReceiveAssembly(
attachment: Attachment,
assemblyKey: string,
total: number
): Promise<DiskReceiveAssembly | null> {
const existing = this.diskReceiveAssemblies.get(assemblyKey);
if (existing) {
return existing;
}
const storageContainer = await this.persistence.resolveStorageContainerName(attachment);
const path = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!path) {
return null;
}
const assembly: DiskReceiveAssembly = {
path,
receivedCount: 0,
receivedIndexes: new Set<number>(),
total
};
this.diskReceiveAssemblies.set(assemblyKey, assembly);
return assembly;
}
private async handleDiskReceiveFailure(
attachment: Attachment,
assemblyKey: string,
error: unknown
): Promise<void> {
await this.deleteDiskReceiveAssembly(assemblyKey);
attachment.available = false;
attachment.objectUrl = undefined;
attachment.receivedBytes = 0;
attachment.speedBps = 0;
attachment.startedAtMs = undefined;
attachment.lastUpdateMs = undefined;
attachment.requestError = error instanceof Error && error.message
? error.message
: 'Media download failed. Retry the download.';
this.runtimeStore.touch();
}
private async deleteDiskReceiveAssembly(assemblyKey: string): Promise<void> {
const assembly = this.diskReceiveAssemblies.get(assemblyKey);
this.diskReceiveAssemblies.delete(assemblyKey);
if (assembly?.path) {
await this.attachmentStorage.deleteFile(assembly.path);
}
}
}

View File

@@ -1,2 +1,5 @@
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** Maximum browser-only audio/video size that renders with an inline media player. */
export const MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB

View File

@@ -7,10 +7,24 @@ import {
sanitizeAttachmentRoomName
} from '../util/attachment-storage.util';
const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:';
@Injectable({ providedIn: 'root' })
export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
canWriteFiles(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
}
canReadFileChunks(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
}
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
@@ -41,10 +55,73 @@ export class AttachmentStorageService {
}
}
async getFileSize(filePath: string): Promise<number | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileSize || !filePath) {
return null;
}
try {
return await electronApi.getFileSize(filePath);
} catch {
return null;
}
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.readFileChunk || !filePath) {
return null;
}
try {
return await electronApi.readFileChunk(filePath, start, end);
} catch {
return null;
}
}
async getFileUrl(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileUrl || !filePath) {
return null;
}
try {
return await electronApi.getFileUrl(filePath);
} catch {
return null;
}
}
async saveBlob(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
blob: Blob,
roomName: string
): Promise<string | null> {
const diskPath = await this.createWritableFile(attachment, roomName);
if (!diskPath) {
return null;
}
try {
const arrayBuffer = await blob.arrayBuffer();
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
} catch {
return null;
}
}
async createWritableFile(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
@@ -54,14 +131,12 @@ export class AttachmentStorageService {
}
try {
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
await this.writeBase64(diskPath, '');
return diskPath;
} catch {
@@ -69,6 +144,20 @@ export class AttachmentStorageService {
}
}
async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFile || !filePath) {
return false;
}
try {
return await electronApi.appendFile(filePath, base64Data);
} catch {
return false;
}
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
@@ -95,6 +184,18 @@ export class AttachmentStorageService {
}
}
private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string {
const bucket = resolveAttachmentStorageBucket(mime);
if (containerName.startsWith(DIRECT_MESSAGE_STORAGE_PREFIX)) {
const conversationId = containerName.slice(DIRECT_MESSAGE_STORAGE_PREFIX.length);
return `${appDataPath}/direct-messages/${sanitizeAttachmentRoomName(conversationId)}/${bucket}`;
}
return `${appDataPath}/server/${sanitizeAttachmentRoomName(containerName)}/${bucket}`;
}
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
@@ -117,6 +218,16 @@ export class AttachmentStorageService {
return null;
}
private async writeBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return false;
}
return await electronApi.writeFile(filePath, base64Data);
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';