fix: attachments broken
Some checks failed
Build Android APK / build-android-apk (push) Failing after 3m5s
Queue Release Build / prepare (push) Successful in 32s
Deploy Web Apps / deploy (push) Successful in 9m30s
Queue Release Build / build-windows (push) Successful in 28m7s
Queue Release Build / build-linux (push) Successful in 47m6s
Queue Release Build / finalize (push) Successful in 58s
Some checks failed
Build Android APK / build-android-apk (push) Failing after 3m5s
Queue Release Build / prepare (push) Successful in 32s
Deploy Web Apps / deploy (push) Successful in 9m30s
Queue Release Build / build-windows (push) Successful in 28m7s
Queue Release Build / build-linux (push) Successful in 47m6s
Queue Release Build / finalize (push) Successful in 58s
This commit is contained in:
@@ -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.
|
- **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`).
|
- **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 `<img>`, `<video>`, or `<audio>` — 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]
|
### 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.
|
- **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.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
|
|||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
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 { 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 { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
|
||||||
@@ -113,7 +113,17 @@ export class AttachmentPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||||
if (attachment.available) {
|
const restored = await this.ensureInlineDisplayObjectUrl(attachment);
|
||||||
|
|
||||||
|
if (restored) {
|
||||||
|
attachment.requestError = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
|
||||||
|
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,19 +144,14 @@ export class AttachmentPersistenceService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, diskPath)) {
|
|
||||||
attachment.requestError = undefined;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64 = await this.attachmentStorage.readFile(diskPath);
|
const base64 = await this.attachmentStorage.readFile(diskPath);
|
||||||
|
|
||||||
if (!base64) {
|
if (!base64) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.revokeAttachmentObjectUrl(attachment);
|
||||||
this.restoreAttachmentFromDisk(attachment, base64);
|
this.restoreAttachmentFromDisk(attachment, base64);
|
||||||
attachment.requestError = undefined;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,24 +296,14 @@ export class AttachmentPersistenceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise<boolean> {
|
private revokeAttachmentObjectUrl(attachment: Attachment): void {
|
||||||
if (!this.isPlayableMedia(attachment)) {
|
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
|
try {
|
||||||
|
URL.revokeObjectURL(attachment.objectUrl);
|
||||||
if (!fileUrl) {
|
} catch { /* ignore */ }
|
||||||
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>> {
|
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export class AttachmentTransferService {
|
|||||||
attachment.requestError = isUploader
|
attachment.requestError = isUploader
|
||||||
? UPLOADER_LOCAL_FILE_MISSING_ERROR
|
? UPLOADER_LOCAL_FILE_MISSING_ERROR
|
||||||
: NO_CONNECTED_PEERS_REQUEST_ERROR;
|
: NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||||
|
|
||||||
this.runtimeStore.touch();
|
this.runtimeStore.touch();
|
||||||
console.warn('[Attachments] No connected peers to request file from');
|
console.warn('[Attachments] No connected peers to request file from');
|
||||||
return;
|
return;
|
||||||
@@ -236,8 +237,8 @@ export class AttachmentTransferService {
|
|||||||
attachment,
|
attachment,
|
||||||
attachment.filePath,
|
attachment.filePath,
|
||||||
this.attachmentStorage.canCopyFiles()
|
this.attachmentStorage.canCopyFiles()
|
||||||
)) {
|
) && attachment.filePath) {
|
||||||
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath!);
|
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
|
||||||
|
|
||||||
if (savedPath) {
|
if (savedPath) {
|
||||||
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
|
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
|
||||||
@@ -659,19 +660,10 @@ export class AttachmentTransferService {
|
|||||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||||
|
|
||||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||||
const diskPath = await this.persistence.saveFileToDisk(attachment, blob);
|
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);
|
attachment.objectUrl = URL.createObjectURL(blob);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
attachment.objectUrl = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
attachment.available = true;
|
attachment.available = true;
|
||||||
this.runtimeStore.touch();
|
this.runtimeStore.touch();
|
||||||
@@ -745,14 +737,14 @@ export class AttachmentTransferService {
|
|||||||
return;
|
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.');
|
throw new Error('Could not open completed media download from disk.');
|
||||||
}
|
}
|
||||||
|
|
||||||
attachment.savedPath = assembly.path;
|
|
||||||
attachment.objectUrl = fileUrl;
|
|
||||||
attachment.available = true;
|
attachment.available = true;
|
||||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||||
this.runtimeStore.touch();
|
this.runtimeStore.touch();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user