# Attachment Domain Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser). ## Module map ``` attachment/ ├── application/ │ ├── facades/ │ │ └── attachment.facade.ts Thin entry point, delegates to manager │ └── services/ │ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners │ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel) │ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming │ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage │ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending) │ ├── domain/ │ ├── logic/ │ │ └── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment │ ├── models/ │ │ ├── attachment.model.ts Attachment type extending AttachmentMeta with runtime state │ │ └── attachment-transfer.model.ts Protocol event types (file-announce, file-chunk, file-request, ...) │ └── constants/ │ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB │ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages │ ├── infrastructure/ │ ├── services/ │ │ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete) │ │ └── capacitor-attachment-export.service.ts Capacitor "download": copy/write bytes into public Documents │ └── util/ │ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket │ └── index.ts Barrel exports ``` ## Service composition The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals). ```mermaid graph TD Facade[AttachmentFacade] Manager[AttachmentManagerService] Transfer[AttachmentTransferService] Transport[AttachmentTransferTransportService] Persistence[AttachmentPersistenceService] Store[AttachmentRuntimeStore] Storage[AttachmentStorageService] Logic[attachment.logic] Facade --> Manager Manager --> Transfer Manager --> Persistence Manager --> Store Manager --> Logic Transfer --> Transport Transfer --> Store Persistence --> Storage Persistence --> Store Storage --> Helpers[attachment-storage.util] click Facade "application/facades/attachment.facade.ts" "Thin entry point" _blank click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank ``` ## File transfer protocol 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 participant R as Receiver S->>R: file-announce (id, name, size, mimeType) Note over R: Store metadata in runtime store Note over R: shouldAutoRequestWhenWatched? R->>S: file-request (attachmentId) Note over S: Look up file in runtime store or on disk loop Every 64 KB chunk S->>R: file-chunk (attachmentId, index, data, progress, speed) 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, or open completed Electron media from disk Note over R: shouldPersistDownloadedAttachment? Save to disk ``` ### Transfer integrity invariants Concurrent triggers (file-announce, message sync, peer connect) can race to request the same file, and a sender can receive duplicate `file-request`s for the same attachment. The transfer service enforces these invariants so duplicate streams can never corrupt a download (regression: receivers used to finalize after only the first chunks): - **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. Files **≤ `MAX_AUTO_SAVE_SIZE_BYTES` (10 MB)** assemble in memory (parallel chunk receive, immediate `file-chunk-ack`) and are persisted after completion via `shouldPersistDownloadedAttachment`. **Oversized** persistable downloads (`> 10 MB`) append directly to disk when the store supports streaming (`canStreamToDisk`) — metadata `filePath` does not force an in-memory fallback. On stores that cannot stream (browser), oversized files the store can still persist (≤ 50 MB) assemble in memory instead. Whether a file can be received at all is decided once, at request time, by `canReceiveAttachment` — `handleFileChunk` must not re-gate on a stricter size cap, or it silently drops chunks the request gate already admitted (the receiver never acks, the sender's ack wait times out, and the download stalls at 0 bytes with no error). 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 **images** ≤ 10 MB get an immediate `objectUrl` blob; oversized images stay on `savedPath` until inline display hydration runs on demand. Completed **audio/video** immediately resolve a playable URL via `attachmentStorage.getFileUrl(savedPath)` (Electron/Capacitor) or `ensureInlineDisplayObjectUrl` in the browser. - **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 and re-queue the guarded auto-download path, allowing a host that returned after `file-not-found` to recover failed inline media without another navigation; pending-request and availability gates prevent duplicate transfers. Outgoing `file-announce` broadcasts are also relayed to sibling devices through `account_sync` (see `infrastructure/realtime/account-sync/account-sync.rules.ts`) so a second client of the same user learns attachment metadata even when it cannot P2P to itself. After reload, serving and re-announcing wait for persisted attachment metadata to finish hydrating. Hosted files are announced even when the uploader is not currently viewing a room, so reconnecting peers can discover them from dashboard and other non-chat routes. If only an original Electron file-picker path survived, the first request copies that file into app data before streaming it; a request must not receive `file-not-found` merely because it raced startup hydration. ```mermaid sequenceDiagram participant R as Receiver participant P1 as Peer A participant P2 as Peer B R->>P1: file-request P1->>R: file-not-found Note over R: Try next peer R->>P2: file-request P2->>R: file-chunk (1/N) P2->>R: file-chunk (2/N) P2->>R: file-chunk (N/N) Note over R: Transfer complete ``` ## Auto-download rules When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic: | Condition | Auto-download? | |---|---| | Image or video, size <= 10 MB | Yes | | Image or video, size > 10 MB | No | | Non-media file | No | The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. Auto-download work fans out with bounded concurrency (`ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY`, default 3 files at a time per watched room) so multiple pending files can progress in parallel without removing the per-file chunk-ack memory safety invariant. Stalled partial downloads are reset automatically before the next auto-download pass — but only when chunk progress has been quiet past `ATTACHMENT_STALLED_DOWNLOAD_THRESHOLD_MS` (`attachment-autodownload.rules.ts`). The pending-request marker is deleted on the first received chunk, so "no pending request" alone must never classify an in-flight transfer as stalled; resetting an active transfer cancels it on the sender and the retry deadlocks against the sender's per-peer active-transfer dedupe. Auto-download triggers must fire from *every* event that can complete the `messageId -> roomId` binding, because `file-announce` (WebRTC data channel) and `chat-message` (signaling websocket) travel on different transports and arrive in either order. When the announce arrives first, the room is still unknown and that pass gives up; the `chat-message` handler (`messages-incoming.handlers.ts`) therefore calls `rememberMessageRoom` and re-queues `queueAutoDownloadsForMessage` for the arriving message. Incoming and synced attachment metadata is normalized through `attachment-normalize.rules.ts` / `attachment-mime.rules.ts`: generic `application/octet-stream` (or empty) MIME types are inferred from the filename extension so images still group into galleries and small audio/video render as players instead of generic file cards. Display hydration (`needsAttachmentDisplayHydration`) rehydrates blob/file URLs from disk for both inline images and playable media without forcing a fresh peer download when local bytes already exist. 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. ## Ownership and the "Shared from your device" label `uploaderPeerId` is the **user** id of whoever uploaded the file, not a per-device id. It is intentionally stable across a user's devices so an uploader can recognise their own attachments after sync. Because of that, "did *this* device upload it?" and "does *this* device hold the bytes?" are two different questions, and the UI must key the *sharing* affordance off the latter. `attachment-sharing.rules.ts` makes this explicit: - `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). ## Persistence Attachment file persistence is platform-agnostic. `AttachmentStorageService` owns the `server//` and `direct-messages/...` path layout and delegates the raw byte IO to a pluggable `AttachmentFileStore` chosen by `PlatformService` (mirroring how `DatabaseService` picks a DB backend): - **Electron** (`ElectronAttachmentFileStore`): real on-disk files via `window.electronAPI`; supports chunked reads and streamed (append) receive; `maxPersistableBytes = Infinity`. - **Capacitor / Android** (`CapacitorAttachmentFileStore`): native `@capacitor/filesystem` under `Directory.Data` (lazy-loaded per the LESSONS rule); inline media is displayed through a `convertFileSrc` webview URL instead of a renderer `Blob`, avoiding large-media memory pressure on mobile; `maxPersistableBytes = Infinity`. - **Browser** (`BrowserAttachmentFileStore`): a per-user IndexedDB virtual filesystem (`metoyou-attachment-files::`, store `files` keyed by path), so a user's own uploads and downloaded media survive reloads; `maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES` (50 MB) so very large media stays peer/in-memory only. The transfer service consults `attachmentStorage.canStreamToDisk()` / `canPersistSize(size)` so the browser cap degrades gracefully (oversized media is kept in memory / peer-served instead of failing the disk path), and streamed receive only runs on stores with a real append primitive. 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}/server/{roomName}/{bucket}/{attachmentId}.{ext?} ``` Direct-message attachments use the conversation id instead of the server-room path: ``` {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. Database hydration merges into attachments already learned or downloaded during startup instead of replacing the runtime map: live `available`, `receivedBytes`, and `objectUrl` state wins, while persisted local paths fill any missing path fields. This prevents late initialization from turning completed downloads back into Retry/spinner/100% states or dropping an announce that arrived during startup. On restore, `ensureInlineDisplayObjectUrl` resolves the stored path and, when the active store exposes a directly loadable URL (`providesInlineObjectUrl`, i.e. Capacitor), uses that URL as-is; otherwise it rebuilds a `Blob` from the stored bytes (Electron via chunked reads, browser via whole-file read with the correct MIME). Because the browser store persists bytes to IndexedDB, sent and received files are remembered across reload/restart on every platform. ## Runtime store `AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for: - **attachments**: all known attachments keyed by ID - **chunks**: incoming chunk buffers during active transfers - **pendingRequests**: outbound requests waiting for a response - **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`). The observer targets the rendered message row rather than the potentially boxless Angular component host, so returning to a channel reliably marks visible rows for rehydration. Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs. - **Bounded hydration:** disk-to-blob display hydration is deduplicated per `(messageId, attachmentId)` and globally limited to two active reads. Leaving/destroying an unpinned message row cancels its queued or active hydration; every IPC chunk and the final object-URL assignment re-check cancellation so stale work from rapid channel switches can never reattach orphaned blobs after teardown. - **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet. - **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room. - **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` (`streamRequestedFile` prefers the disk path); blob URLs are display-only. While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`). Chat attachment images keep plain `[src]` bindings because Angular's `NgOptimizedImage` rejects runtime `blob:` URLs (`NG02952`). Inline, grid, and gallery thumbnails use native `loading="lazy"` and `decoding="async"`; fullscreen lightbox images remain eager. ## Cross-context feature docs - [`agents-docs/features/attachments.md`](../../../../../agents-docs/features/attachments.md)