diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md
index ca2f51e..506e8c5 100644
--- a/agents-docs/LESSONS.md
+++ b/agents-docs/LESSONS.md
@@ -102,6 +102,13 @@ Durable rules for AI agents working on this project. Read this file at session s
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
+### Use blob URLs for inline attachment previews [attachments] [electron]
+
+- **Trigger:** receiving users see broken image icons or video players that never start, but "Download" saves a valid file.
+- **Rule:** never bind `attachment.objectUrl` to `file://` URLs for chat ` `, ``, or `` — always create a `blob:` URL from the bytes on disk or in memory; keep `savedPath`/`filePath` for IPC download/open only.
+- **Why:** Electron runs with `webSecurity: true`, so renderer pages cannot load arbitrary `file://` app-data paths even when CSP allows `file:`; IPC download still works because it reads the path in the main process.
+- **Example:** `ensureInlineDisplayObjectUrl()` in `AttachmentPersistenceService`, and `URL.createObjectURL(blob)` in `finalizeTransferIfComplete` / `handleDiskFileChunk` instead of `getFileUrl(savedPath)`.
+
### Resolve Electron drag-and-drop file paths with webUtils [attachments] [electron]
- **Trigger:** large videos play after drag-and-drop upload, but after restart the uploader sees a peer-download error even though they sent the file from disk.
diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts
index 825a75e..51a560c 100644
--- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts
+++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts
@@ -5,8 +5,8 @@ import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
-import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
+import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@@ -113,7 +113,17 @@ export class AttachmentPersistenceService {
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise {
- if (attachment.available) {
+ const restored = await this.ensureInlineDisplayObjectUrl(attachment);
+
+ if (restored) {
+ attachment.requestError = undefined;
+ }
+
+ return restored;
+ }
+
+ async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise {
+ if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
return true;
}
@@ -134,19 +144,14 @@ export class AttachmentPersistenceService {
return false;
}
- if (await this.restoreMediaAttachmentFromFileUrl(attachment, diskPath)) {
- attachment.requestError = undefined;
- return true;
- }
-
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
+ this.revokeAttachmentObjectUrl(attachment);
this.restoreAttachmentFromDisk(attachment, base64);
- attachment.requestError = undefined;
return true;
}
@@ -291,24 +296,14 @@ export class AttachmentPersistenceService {
);
}
- private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise {
- if (!this.isPlayableMedia(attachment)) {
- return false;
+ private revokeAttachmentObjectUrl(attachment: Attachment): void {
+ if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
+ return;
}
- const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
-
- if (!fileUrl) {
- return false;
- }
-
- attachment.objectUrl = fileUrl;
- attachment.available = true;
- return true;
- }
-
- private isPlayableMedia(attachment: Pick): boolean {
- return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
+ try {
+ URL.revokeObjectURL(attachment.objectUrl);
+ } catch { /* ignore */ }
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> {
diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts
index 7b666e0..4a09b11 100644
--- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts
+++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts
@@ -148,6 +148,7 @@ export class AttachmentTransferService {
attachment.requestError = isUploader
? UPLOADER_LOCAL_FILE_MISSING_ERROR
: NO_CONNECTED_PEERS_REQUEST_ERROR;
+
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
@@ -236,8 +237,8 @@ export class AttachmentTransferService {
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
- )) {
- const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath!);
+ ) && attachment.filePath) {
+ const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
@@ -659,20 +660,11 @@ export class AttachmentTransferService {
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);
+ await this.persistence.saveFileToDisk(attachment, blob);
}
+ attachment.objectUrl = URL.createObjectURL(blob);
+
attachment.available = true;
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
@@ -745,14 +737,14 @@ export class AttachmentTransferService {
return;
}
- const fileUrl = await this.attachmentStorage.getFileUrl(assembly.path);
+ attachment.savedPath = assembly.path;
- if (!fileUrl) {
+ const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
+
+ if (!restoredForDisplay) {
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();
diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.spec.ts
new file mode 100644
index 0000000..ebc4094
--- /dev/null
+++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.spec.ts
@@ -0,0 +1,20 @@
+import {
+ isBlobObjectUrl,
+ isFileProtocolObjectUrl,
+ needsBlobObjectUrlForInlineDisplay
+} from './attachment-display-url.rules';
+
+describe('attachment display url rules', () => {
+ it('detects blob and file protocol urls', () => {
+ expect(isBlobObjectUrl('blob:http://localhost/abc')).toBe(true);
+ expect(isBlobObjectUrl('file:///tmp/video.mp4')).toBe(false);
+ expect(isFileProtocolObjectUrl('file:///tmp/video.mp4')).toBe(true);
+ expect(isFileProtocolObjectUrl('blob:http://localhost/abc')).toBe(false);
+ });
+
+ it('requires blob urls for inline display when missing or file protocol', () => {
+ expect(needsBlobObjectUrlForInlineDisplay(undefined)).toBe(true);
+ expect(needsBlobObjectUrlForInlineDisplay('file:///appdata/image.png')).toBe(true);
+ expect(needsBlobObjectUrlForInlineDisplay('blob:http://localhost/abc')).toBe(false);
+ });
+});
diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.ts
new file mode 100644
index 0000000..ce927bc
--- /dev/null
+++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-display-url.rules.ts
@@ -0,0 +1,11 @@
+export function isBlobObjectUrl(url: string): boolean {
+ return url.startsWith('blob:');
+}
+
+export function isFileProtocolObjectUrl(url: string): boolean {
+ return url.startsWith('file:');
+}
+
+export function needsBlobObjectUrlForInlineDisplay(objectUrl?: string): boolean {
+ return !objectUrl || isFileProtocolObjectUrl(objectUrl);
+}