fix: Bug - Local files should be remembered by client

This commit is contained in:
2026-06-11 01:54:00 +02:00
parent 5bf4f698df
commit 494a05e606
19 changed files with 1611 additions and 143 deletions

View File

@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile]
- **Trigger:** `AttachmentStorageService` talked only to `window.electronAPI`, so `canWriteFiles()` returned `false` on Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file".
- **Rule:** keep the path/bucket layout in `AttachmentStorageService` but delegate raw IO to a pluggable `AttachmentFileStore` selected by `PlatformService` — Electron disk, Capacitor `Directory.Data` (lazy-loaded, inline media via `convertFileSrc`), and a per-user IndexedDB vfs for the browser with a finite `maxPersistableBytes` cap; gate transfer persistence on `canStreamToDisk()` / `canPersistSize()` so the cap degrades gracefully.
- **Why:** the browser e2e harness can't test native disk, but the browser IndexedDB store is real persistence, so a single-client send → `page.reload()` → reopen-room test proves the whole persist/restore orchestration with no peer connected.
- **Example:** `attachment-file-store.ts` + `{electron,browser,capacitor}-attachment-file-store.ts`; `e2e/tests/chat/local-attachment-persistence.spec.ts` waits for both byte records (vfs) **and** `attachments` records with `savedPath` (summed across all `metoyou`/`metoyou::<user>` DBs, since an empty anonymous-scope DB exists) before reloading.
### Never count duplicate chunks toward transfer progress, and never finalize on byte counters [attachments] [webrtc] ### Never count duplicate chunks toward transfer progress, and never finalize on byte counters [attachments] [webrtc]
- **Trigger:** P2P attachments arrived corrupt everywhere ("only the first bytes") because concurrent auto-download triggers double-requested a file, the sender streamed it twice, and the receiver counted duplicate chunk deliveries toward `receivedBytes` — inflating it past `size`, which both dropped the remaining chunks (post-Security guard) and passed the `receivedBytes >= size` finalize shortcut over a sparse buffer. - **Trigger:** P2P attachments arrived corrupt everywhere ("only the first bytes") because concurrent auto-download triggers double-requested a file, the sender streamed it twice, and the receiver counted duplicate chunk deliveries toward `receivedBytes` — inflating it past `size`, which both dropped the remaining chunks (post-Security guard) and passed the `receivedBytes >= size` finalize shortcut over a sparse buffer.

View File

@@ -0,0 +1,244 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
const UPLOADER_LOCAL_MISSING_TEXT = 'Your original upload could not be found on this device';
test.describe('Local attachment persistence', () => {
test.describe.configure({ timeout: 180_000 });
test('remembers sent image and file across a page reload with no peer connected', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const serverName = `Persist Server ${uniqueName('persist')}`;
const imageName = `${uniqueName('diagram')}.svg`;
const fileName = `${uniqueName('notes')}.txt`;
const imageCaption = `Persisted image ${uniqueName('caption')}`;
const fileCaption = `Persisted file ${uniqueName('caption')}`;
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
await test.step('Create a server and open its room', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, serverName, 'Local attachment persistence server');
});
await test.step('Send an image and a generic file attachment', async () => {
await scenario.messages.attachFiles([imageAttachment]);
await scenario.messages.sendMessage(imageCaption);
await scenario.messages.expectMessageImageLoaded(imageName);
await scenario.messages.attachFiles([fileAttachment]);
await scenario.messages.sendMessage(fileCaption);
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
});
await test.step('Wait for both attachments to be persisted locally', async () => {
await waitForPersistedAttachmentBytes(scenario.client.page, 2);
await waitForPersistedAttachmentRecords(scenario.client.page, 2);
});
await test.step('Reload the page to simulate an application restart', async () => {
await scenario.client.page.reload();
await expect(scenario.client.page).toHaveURL(/\/(room|dashboard)/, { timeout: 30_000 });
await openSavedRoomByName(scenario.client.page, serverName);
await expect(scenario.messages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
});
await test.step('The image still renders from local storage with no peer', async () => {
await scenario.messages.expectMessageImageLoaded(imageName);
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
});
await test.step('The generic file is still remembered with no missing-upload error', async () => {
await expect(scenario.messages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
});
});
});
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
async function createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
interface CountOptions {
databaseRole: 'attachment-files' | 'app';
storeName: string;
requireSavedPath: boolean;
}
/** Counts records in the first matching IndexedDB store, optionally requiring a savedPath. */
async function countIndexedDbRecords(page: Page, options: CountOptions): Promise<number> {
return page.evaluate(async (countOptions: CountOptions) => {
if (typeof indexedDB.databases !== 'function') {
return 0;
}
const databases = await indexedDB.databases();
const matchingNames = databases
.map((entry) => entry.name ?? '')
.filter((name) => (countOptions.databaseRole === 'attachment-files'
? name.startsWith('metoyou-attachment-files')
: name === 'metoyou' || name.startsWith('metoyou::')));
const countInDatabase = (databaseName: string): Promise<number> => new Promise<number>((resolve) => {
const request = indexedDB.open(databaseName);
request.onerror = () => resolve(0);
request.onsuccess = () => {
const database = request.result;
if (!database.objectStoreNames.contains(countOptions.storeName)) {
database.close();
resolve(0);
return;
}
const getAll = database.transaction(countOptions.storeName, 'readonly')
.objectStore(countOptions.storeName)
.getAll();
getAll.onsuccess = () => {
const records = (getAll.result as { savedPath?: string }[]) ?? [];
const matching = countOptions.requireSavedPath
? records.filter((record) => !!record.savedPath)
: records;
resolve(matching.length);
database.close();
};
getAll.onerror = () => {
resolve(0);
database.close();
};
};
});
const counts = await Promise.all(matchingNames.map(countInDatabase));
return counts.reduce((total, count) => total + count, 0);
}, options);
}
/** Polls until at least `minCount` attachment byte records exist in the browser file store. */
async function waitForPersistedAttachmentBytes(page: Page, minCount: number): Promise<void> {
await expect.poll(
async () => countIndexedDbRecords(page, { databaseRole: 'attachment-files', storeName: 'files', requireSavedPath: false }),
{ timeout: 20_000, message: 'attachment bytes should persist to IndexedDB before reload' }
).toBeGreaterThanOrEqual(minCount);
}
/** Polls until at least `minCount` attachment metadata records with a savedPath exist in the app database. */
async function waitForPersistedAttachmentRecords(page: Page, minCount: number): Promise<void> {
await expect.poll(
async () => countIndexedDbRecords(page, { databaseRole: 'app', storeName: 'attachments', requireSavedPath: true }),
{ timeout: 20_000, message: 'attachment metadata with savedPath should persist before reload' }
).toBeGreaterThanOrEqual(minCount);
}
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
return {
name,
mimeType,
base64: Buffer.from(content, 'utf8').toString('base64')
};
}
function buildMockSvgMarkup(label: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
'</svg>'
].join('');
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

11
package-lock.json generated
View File

@@ -93,6 +93,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-newlines": "^1.4.1", "eslint-plugin-import-newlines": "^1.4.1",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
"fake-indexeddb": "^6.2.5",
"glob": "^10.5.0", "glob": "^10.5.0",
"pkg": "^5.8.1", "pkg": "^5.8.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -20431,6 +20432,16 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View File

@@ -153,6 +153,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-newlines": "^1.4.1", "eslint-plugin-import-newlines": "^1.4.1",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
"fake-indexeddb": "^6.2.5",
"glob": "^10.5.0", "glob": "^10.5.0",
"pkg": "^5.8.1", "pkg": "^5.8.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",

View File

@@ -149,6 +149,14 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
## Persistence ## Persistence
Attachment file persistence is platform-agnostic. `AttachmentStorageService` owns the `server/<room>/<bucket>` 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::<userId>`, 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: 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:
``` ```
@@ -163,7 +171,7 @@ Direct-message attachments use the conversation id instead of the server-room pa
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. 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. `AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. 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 ## Runtime store

View File

@@ -32,7 +32,9 @@ describe('AttachmentPersistenceService', () => {
readFile: ReturnType<typeof vi.fn>; readFile: ReturnType<typeof vi.fn>;
readFileChunk: ReturnType<typeof vi.fn>; readFileChunk: ReturnType<typeof vi.fn>;
getFileSize: ReturnType<typeof vi.fn>; getFileSize: ReturnType<typeof vi.fn>;
getFileUrl: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>; canReadFileChunks: ReturnType<typeof vi.fn>;
providesInlineObjectUrl: ReturnType<typeof vi.fn>;
}; };
beforeEach(() => { beforeEach(() => {
@@ -60,7 +62,9 @@ describe('AttachmentPersistenceService', () => {
readFile: vi.fn(() => Promise.resolve('QUJD')), readFile: vi.fn(() => Promise.resolve('QUJD')),
readFileChunk: vi.fn(() => Promise.resolve('QUJD')), readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
getFileSize: vi.fn(() => Promise.resolve(3)), getFileSize: vi.fn(() => Promise.resolve(3)),
canReadFileChunks: vi.fn(() => true) getFileUrl: vi.fn(() => Promise.resolve(null)),
canReadFileChunks: vi.fn(() => true),
providesInlineObjectUrl: vi.fn(() => false)
}; };
}); });
@@ -112,4 +116,57 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFileChunk).toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled();
}); });
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
attachmentStorage.canReadFileChunks.mockReturnValue(false);
const service = createService();
await service.initFromDatabase();
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.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(attachmentStorage.readFile).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
});
it('uses a native webview URL without rebuilding a blob (capacitor store)', async () => {
attachmentStorage.providesInlineObjectUrl.mockReturnValue(true);
attachmentStorage.resolveExistingPath.mockResolvedValue('metoyou/server/room/video/clip.mp4');
attachmentStorage.getFileUrl.mockResolvedValue('capacitor://localhost/_capacitor_file_/clip.mp4');
const service = createService();
await service.initFromDatabase();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'clip.mp4',
size: 1_024,
mime: 'video/mp4',
isImage: false,
savedPath: 'metoyou/server/room/video/clip.mp4',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toBe('capacitor://localhost/_capacitor_file_/clip.mp4');
expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith('metoyou/server/room/video/clip.mp4');
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
});
}); });

View File

@@ -149,6 +149,17 @@ export class AttachmentPersistenceService {
return false; return false;
} }
if (this.attachmentStorage.providesInlineObjectUrl()) {
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
if (nativeUrl) {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl;
attachment.available = true;
return true;
}
}
this.revokeAttachmentObjectUrl(attachment); this.revokeAttachmentObjectUrl(attachment);
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath); const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);

View File

@@ -46,6 +46,8 @@ describe('AttachmentTransferService', () => {
canWriteFiles: ReturnType<typeof vi.fn>; canWriteFiles: ReturnType<typeof vi.fn>;
canCopyFiles: ReturnType<typeof vi.fn>; canCopyFiles: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>; canReadFileChunks: ReturnType<typeof vi.fn>;
canStreamToDisk: ReturnType<typeof vi.fn>;
canPersistSize: ReturnType<typeof vi.fn>;
getFileUrl: ReturnType<typeof vi.fn>; getFileUrl: ReturnType<typeof vi.fn>;
resolveExistingPath: ReturnType<typeof vi.fn>; resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>; resolveLegacyImagePath: ReturnType<typeof vi.fn>;
@@ -80,6 +82,8 @@ describe('AttachmentTransferService', () => {
canWriteFiles: vi.fn(() => false), canWriteFiles: vi.fn(() => false),
canCopyFiles: vi.fn(() => false), canCopyFiles: vi.fn(() => false),
canReadFileChunks: vi.fn(() => false), canReadFileChunks: vi.fn(() => false),
canStreamToDisk: vi.fn(() => false),
canPersistSize: vi.fn(() => true),
getFileUrl: vi.fn(async () => null), getFileUrl: vi.fn(async () => null),
resolveExistingPath: vi.fn(async () => null), resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null), resolveLegacyImagePath: vi.fn(async () => null),
@@ -343,6 +347,60 @@ describe('AttachmentTransferService', () => {
expect(isCancelled()).toBe(false); expect(isCancelled()).toBe(false);
}); });
function registerIncomingVideo(size: number): Attachment {
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'clip.mp4',
size,
mime: 'video/mp4',
isImage: false,
uploaderPeerId: PEER_ID,
available: false,
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
return attachment;
}
it('streams playable media to disk when the store supports streaming', 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(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
});
it('assembles playable media in memory when the store cannot stream to disk', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
const service = createService();
const attachment = registerIncomingVideo(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
});
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => { it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
const service = createService(); const service = createService();
const attachment = registerIncomingAttachment(9); const attachment = registerIncomingAttachment(9);

View File

@@ -257,39 +257,7 @@ export class AttachmentTransferService {
} catch { /* non-critical */ } } catch { /* non-critical */ }
} }
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { await this.persistPublishedAttachment(attachment, file);
void this.persistence.saveFileToDisk(attachment, file);
} else if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
) && attachment.filePath) {
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
} else if (
this.isPlayableMedia(attachment) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES &&
this.attachmentStorage.canWriteFiles()
) {
const savedPath = await this.persistence.saveFileToDisk(attachment, file);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
}
const fileAnnounceEvent: FileAnnounceEvent = { const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce', type: 'file-announce',
@@ -760,12 +728,63 @@ export class AttachmentTransferService {
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
} }
/**
* Persist an outgoing attachment so it survives restart/logout-login: small
* files are auto-saved, oversized uploader media is copied or streamed to the
* active store, and the inline object URL is upgraded to the saved file when
* the store provides one. Oversized media on capped stores (browser) stays in
* memory / peer-served and degrades gracefully.
*/
private async persistPublishedAttachment(attachment: Attachment, file: File): Promise<void> {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
return;
}
if (!this.attachmentStorage.canPersistSize(attachment.size)) {
return;
}
if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
) && attachment.filePath) {
await this.applySavedPathObjectUrl(
attachment,
await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath)
);
return;
}
if (this.isPlayableMedia(attachment) && this.attachmentStorage.canWriteFiles()) {
await this.applySavedPathObjectUrl(attachment, await this.persistence.saveFileToDisk(attachment, file));
}
}
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
if (!savedPath) {
return;
}
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean { private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'); return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
} }
private shouldReceiveToDisk(attachment: Attachment): boolean { private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles(); return this.isPlayableMedia(attachment) &&
!attachment.filePath &&
this.attachmentStorage.canStreamToDisk() &&
this.attachmentStorage.canPersistSize(attachment.size);
} }
private enqueueDiskFileChunk( private enqueueDiskFileChunk(

View File

@@ -1,6 +1,9 @@
/** Chunk size used when rebuilding attachment blobs from disk without blocking the UI thread. */ /** Chunk size used when rebuilding attachment blobs from disk without blocking the UI thread. */
export const ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES = 256 * 1024; export const ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES = 256 * 1024;
/** Number of bytes encoded per chunk to keep base64 encoding off the call stack. */
const BASE64_ENCODE_CHUNK_SIZE = 0x8000;
/** Decode a base64 payload into bytes for Blob construction. */ /** Decode a base64 payload into bytes for Blob construction. */
export function decodeBase64ToUint8Array(base64: string): Uint8Array { export function decodeBase64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64); const binary = atob(base64);
@@ -13,6 +16,19 @@ export function decodeBase64ToUint8Array(base64: string): Uint8Array {
return bytes; return bytes;
} }
/** Encode bytes into a base64 payload, chunked so large buffers cannot overflow the call stack. */
export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += BASE64_ENCODE_CHUNK_SIZE) {
const chunk = bytes.subarray(offset, offset + BASE64_ENCODE_CHUNK_SIZE);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */ /** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> { export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -0,0 +1,58 @@
/**
* Low-level file persistence primitives for attachments, abstracted over the
* runtime shell (Electron disk, Capacitor Filesystem, browser IndexedDB).
*
* `AttachmentStorageService` owns the higher-level path resolution and bucket
* layout; it delegates raw IO to the active {@link AttachmentFileStore} so the
* "remember local files" contract holds on every platform.
*
* All file paths passed to a store are produced by `AttachmentStorageService`
* from `getAppDataPath()` plus the `server/<room>/<bucket>/<id>` layout, so each
* store decides how that string maps onto its own backing storage.
*/
export interface AttachmentFileStore {
/** Whether this store can persist attachment bytes in the current runtime. */
readonly isAvailable: boolean;
/**
* Largest file (bytes) this store will persist. Backends with a real
* filesystem return `Infinity`; the browser store caps to avoid bloating
* IndexedDB with very large media.
*/
readonly maxPersistableBytes: number;
/**
* Whether the store supports appending chunks directly to a file as they
* arrive (streamed receive). Backends without a real append primitive return
* `false`, so the transfer service assembles those downloads in memory and
* writes them once on completion.
*/
readonly supportsStreamingToDisk: boolean;
/**
* Whether {@link readFileChunk} can return an arbitrary byte range cheaply.
* When `false`, blob restore reads the whole file once via {@link readFile}.
*/
readonly supportsChunkedReads: boolean;
/**
* Whether {@link getFileUrl} returns a URL the webview can load directly for
* inline display (e.g. Capacitor `convertFileSrc`). When `true`, restore uses
* that URL instead of rebuilding a renderer `Blob`, avoiding memory pressure
* for large media on mobile. Electron returns `false` because its `file://`
* URLs are blocked by `webSecurity`.
*/
readonly providesInlineObjectUrl: boolean;
getAppDataPath(): Promise<string | null>;
ensureDir(dirPath: string): Promise<boolean>;
writeFile(filePath: string, base64Data: string): Promise<boolean>;
appendFile(filePath: string, base64Data: string): Promise<boolean>;
readFile(filePath: string): Promise<string | null>;
readFileChunk(filePath: string, start: number, end: number): Promise<string | null>;
getFileSize(filePath: string): Promise<number | null>;
fileExists(filePath: string): Promise<boolean>;
copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean>;
deleteFile(filePath: string): Promise<void>;
getFileUrl(filePath: string): Promise<string | null>;
}

View File

@@ -0,0 +1,99 @@
import '@angular/compiler';
import {
describe,
expect,
it
} from 'vitest';
import { Injector, runInInjectionContext } from '@angular/core';
import { PlatformService } from '../../../../core/platform/platform.service';
import type { AttachmentFileStore } from './attachment-file-store';
import { AttachmentStorageService } from './attachment-storage.service';
import { BrowserAttachmentFileStore } from './browser-attachment-file-store';
import { CapacitorAttachmentFileStore } from './capacitor-attachment-file-store';
import { ElectronAttachmentFileStore } from './electron-attachment-file-store';
function createStore(overrides: Partial<AttachmentFileStore>): AttachmentFileStore {
return {
isAvailable: false,
maxPersistableBytes: Number.POSITIVE_INFINITY,
supportsStreamingToDisk: false,
supportsChunkedReads: false,
providesInlineObjectUrl: false,
getAppDataPath: async () => null,
ensureDir: async () => false,
writeFile: async () => false,
appendFile: async () => false,
readFile: async () => null,
readFileChunk: async () => null,
getFileSize: async () => null,
fileExists: async () => false,
copyFile: async () => false,
deleteFile: async () => undefined,
getFileUrl: async () => null,
...overrides
};
}
function createService(platform: Partial<PlatformService>, stores: {
electron?: Partial<AttachmentFileStore>;
capacitor?: Partial<AttachmentFileStore>;
browser?: Partial<AttachmentFileStore>;
}): AttachmentStorageService {
const injector = Injector.create({
providers: [
AttachmentStorageService,
{ provide: PlatformService, useValue: platform },
{ provide: ElectronAttachmentFileStore, useValue: createStore(stores.electron ?? {}) },
{ provide: CapacitorAttachmentFileStore, useValue: createStore(stores.capacitor ?? {}) },
{ provide: BrowserAttachmentFileStore, useValue: createStore(stores.browser ?? {}) }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentStorageService));
}
describe('AttachmentStorageService backend selection', () => {
it('selects the Electron store on the desktop shell', () => {
const service = createService(
{ isElectron: true, isCapacitor: false, isBrowser: false },
{ electron: { isAvailable: true, supportsChunkedReads: true } }
);
expect(service.canWriteFiles()).toBe(true);
expect(service.canReadFileChunks()).toBe(true);
expect(service.canStreamToDisk()).toBe(false);
});
it('selects the Capacitor store on a native mobile shell', () => {
const service = createService(
{ isElectron: false, isCapacitor: true, isBrowser: false },
{ capacitor: { isAvailable: true, supportsStreamingToDisk: true, providesInlineObjectUrl: true } }
);
expect(service.canWriteFiles()).toBe(true);
expect(service.canStreamToDisk()).toBe(true);
expect(service.providesInlineObjectUrl()).toBe(true);
});
it('selects the browser store and reflects its finite size cap', () => {
const service = createService(
{ isElectron: false, isCapacitor: false, isBrowser: true },
{ browser: { isAvailable: true, supportsChunkedReads: true, maxPersistableBytes: 100 } }
);
expect(service.canWriteFiles()).toBe(true);
expect(service.canStreamToDisk()).toBe(false);
expect(service.canPersistSize(50)).toBe(true);
expect(service.canPersistSize(150)).toBe(false);
});
it('reports no capabilities when the selected store is unavailable', () => {
const service = createService(
{ isElectron: false, isCapacitor: false, isBrowser: true },
{ browser: { isAvailable: false } }
);
expect(service.canWriteFiles()).toBe(false);
expect(service.canPersistSize(1)).toBe(false);
});
});

View File

@@ -1,35 +1,59 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { PlatformService } from '../../../../core/platform/platform.service';
import type { Attachment } from '../../domain/models/attachment.model'; import type { Attachment } from '../../domain/models/attachment.model';
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import { import {
isAllowedAttachmentStoredPath, isAllowedAttachmentStoredPath,
resolveAttachmentStorageBucket, resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename, resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName sanitizeAttachmentRoomName
} from '../util/attachment-storage.util'; } from '../util/attachment-storage.util';
import type { AttachmentFileStore } from './attachment-file-store';
import { BrowserAttachmentFileStore } from './browser-attachment-file-store';
import { CapacitorAttachmentFileStore } from './capacitor-attachment-file-store';
import { ElectronAttachmentFileStore } from './electron-attachment-file-store';
const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:'; const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:';
/**
* High-level attachment file persistence. Owns the `server/<room>/<bucket>` and
* `direct-messages/...` path layout and delegates raw IO to the
* {@link AttachmentFileStore} selected for the active runtime, so a user's local
* files are remembered on Electron, Capacitor (Android) and the browser alike.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentStorageService { export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService); private readonly platform = inject(PlatformService);
private readonly electronStore = inject(ElectronAttachmentFileStore);
private readonly capacitorStore = inject(CapacitorAttachmentFileStore);
private readonly browserStore = inject(BrowserAttachmentFileStore);
private readonly store: AttachmentFileStore = this.selectStore();
canWriteFiles(): boolean { canWriteFiles(): boolean {
const electronApi = this.electronBridge.getApi(); return this.store.isAvailable;
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
} }
canReadFileChunks(): boolean { canReadFileChunks(): boolean {
const electronApi = this.electronBridge.getApi(); return this.store.isAvailable && this.store.supportsChunkedReads;
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
} }
canCopyFiles(): boolean { canCopyFiles(): boolean {
const electronApi = this.electronBridge.getApi(); return this.store.isAvailable;
}
return !!electronApi?.copyFile && !!electronApi.ensureDir && !!electronApi.getAppDataPath; /** Whether the active store can stream a download to disk chunk-by-chunk as it arrives. */
canStreamToDisk(): boolean {
return this.store.isAvailable && this.store.supportsStreamingToDisk;
}
/** Whether a file of the given size can be persisted by the active store (browser caps large media). */
canPersistSize(bytes: number): boolean {
return this.store.isAvailable && bytes <= this.store.maxPersistableBytes;
}
/** Whether {@link getFileUrl} yields a URL the webview can load directly for inline display. */
providesInlineObjectUrl(): boolean {
return this.store.providesInlineObjectUrl;
} }
async resolveExistingPath( async resolveExistingPath(
@@ -55,17 +79,11 @@ export class AttachmentStorageService {
} }
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> { async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi(); if (!sourceFilePath || !destinationFilePath) {
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
return false; return false;
} }
try { return this.store.copyFile(sourceFilePath, destinationFilePath);
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
} catch {
return false;
}
} }
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> { async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
@@ -81,59 +99,35 @@ export class AttachmentStorageService {
} }
async readFile(filePath: string): Promise<string | null> { async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi || !filePath) {
return null; return null;
} }
try { return this.store.readFile(filePath);
return await electronApi.readFile(filePath);
} catch {
return null;
}
} }
async getFileSize(filePath: string): Promise<number | null> { async getFileSize(filePath: string): Promise<number | null> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi?.getFileSize || !filePath) {
return null; return null;
} }
try { return this.store.getFileSize(filePath);
return await electronApi.getFileSize(filePath);
} catch {
return null;
}
} }
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> { async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi?.readFileChunk || !filePath) {
return null; return null;
} }
try { return this.store.readFileChunk(filePath, start, end);
return await electronApi.readFileChunk(filePath, start, end);
} catch {
return null;
}
} }
async getFileUrl(filePath: string): Promise<string | null> { async getFileUrl(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi?.getFileUrl || !filePath) {
return null; return null;
} }
try { return this.store.getFileUrl(filePath);
return await electronApi.getFileUrl(filePath);
} catch {
return null;
}
} }
async saveBlob( async saveBlob(
@@ -141,6 +135,10 @@ export class AttachmentStorageService {
blob: Blob, blob: Blob,
roomName: string roomName: string
): Promise<string | null> { ): Promise<string | null> {
if (!this.canPersistSize(blob.size)) {
return null;
}
const diskPath = await this.createWritableFile(attachment, roomName); const diskPath = await this.createWritableFile(attachment, roomName);
if (!diskPath) { if (!diskPath) {
@@ -149,10 +147,9 @@ export class AttachmentStorageService {
try { try {
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const wrote = await this.store.writeFile(diskPath, encodeUint8ArrayToBase64(new Uint8Array(arrayBuffer)));
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer)); return wrote ? diskPath : null;
return diskPath;
} catch { } catch {
return null; return null;
} }
@@ -162,20 +159,19 @@ export class AttachmentStorageService {
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>, attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string roomName: string
): Promise<string | null> { ): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath(); const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) { if (!appDataPath) {
return null; return null;
} }
try { try {
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime); const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
await electronApi.ensureDir(directoryPath); await this.store.ensureDir(directoryPath);
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`; const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await this.writeBase64(diskPath, ''); await this.store.writeFile(diskPath, '');
return diskPath; return diskPath;
} catch { } catch {
@@ -184,43 +180,35 @@ export class AttachmentStorageService {
} }
async appendBase64(filePath: string, base64Data: string): Promise<boolean> { async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi?.appendFile || !filePath) {
return false; return false;
} }
try { return this.store.appendFile(filePath, base64Data);
return await electronApi.appendFile(filePath, base64Data);
} catch {
return false;
}
} }
async deleteFile(filePath: string): Promise<void> { async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi(); if (!filePath) {
if (!electronApi || !filePath) {
return; return;
} }
try { await this.store.deleteFile(filePath);
await electronApi.deleteFile(filePath); }
} catch { /* best-effort cleanup */ }
private selectStore(): AttachmentFileStore {
if (this.platform.isElectron) {
return this.electronStore;
}
if (this.platform.isCapacitor) {
return this.capacitorStore;
}
return this.browserStore;
} }
private async resolveAppDataPath(): Promise<string | null> { private async resolveAppDataPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); return this.store.getAppDataPath();
if (!electronApi) {
return null;
}
try {
return await electronApi.getAppDataPath();
} catch {
return null;
}
} }
private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string { private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string {
@@ -236,10 +224,9 @@ export class AttachmentStorageService {
} }
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> { private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath(); const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) { if (!appDataPath) {
return null; return null;
} }
@@ -249,7 +236,7 @@ export class AttachmentStorageService {
} }
try { try {
if (await electronApi.fileExists(candidatePath)) { if (await this.store.fileExists(candidatePath)) {
return candidatePath; return candidatePath;
} }
} catch { /* keep trying remaining candidates */ } } catch { /* keep trying remaining candidates */ }
@@ -257,26 +244,4 @@ export class AttachmentStorageService {
return null; 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 = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
} }

View File

@@ -0,0 +1,90 @@
import 'fake-indexeddb/auto';
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import { BrowserAttachmentFileStore } from './browser-attachment-file-store';
function base64Of(text: string): string {
return encodeUint8ArrayToBase64(new TextEncoder().encode(text));
}
function decodeBase64(base64: string): string {
return new TextDecoder().decode(Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)));
}
describe('BrowserAttachmentFileStore', () => {
let store: BrowserAttachmentFileStore;
let path: string;
beforeEach(() => {
store = new BrowserAttachmentFileStore();
path = `metoyou-attachment-files/server/room/files/${crypto.randomUUID()}.txt`;
});
it('reports availability and a synthetic app-data root', async () => {
expect(store.isAvailable).toBe(true);
await expect(store.getAppDataPath()).resolves.toBe('metoyou-attachment-files');
});
it('writes and reads a file round-trip', async () => {
await expect(store.writeFile(path, base64Of('hello world'))).resolves.toBe(true);
await expect(store.fileExists(path)).resolves.toBe(true);
await expect(store.getFileSize(path)).resolves.toBe('hello world'.length);
const readBack = await store.readFile(path);
expect(readBack).not.toBeNull();
expect(decodeBase64(readBack as string)).toBe('hello world');
});
it('appends chunks and reads arbitrary byte ranges', async () => {
await store.writeFile(path, '');
await store.appendFile(path, base64Of('abc'));
await store.appendFile(path, base64Of('defg'));
await expect(store.getFileSize(path)).resolves.toBe(7);
const chunk = await store.readFileChunk(path, 2, 5);
expect(decodeBase64(chunk as string)).toBe('cde');
});
it('copies and deletes files', async () => {
const destination = `metoyou-attachment-files/server/room/files/${crypto.randomUUID()}.txt`;
await store.writeFile(path, base64Of('payload'));
await expect(store.copyFile(path, destination)).resolves.toBe(true);
expect(decodeBase64((await store.readFile(destination)) as string)).toBe('payload');
await store.deleteFile(path);
await expect(store.fileExists(path)).resolves.toBe(false);
await expect(store.fileExists(destination)).resolves.toBe(true);
});
it('produces a blob URL for stored bytes', async () => {
await store.writeFile(path, base64Of('blobbable'));
const url = await store.getFileUrl(path);
expect(url).toMatch(/^blob:/);
});
it('refuses to persist files larger than the browser cap', async () => {
const oversizedStore = new BrowserAttachmentFileStore();
Object.defineProperty(oversizedStore, 'maxPersistableBytes', { value: 4 });
await expect(oversizedStore.writeFile(path, base64Of('toolong'))).resolves.toBe(false);
await expect(oversizedStore.fileExists(path)).resolves.toBe(false);
});
it('returns null for unknown files', async () => {
await expect(store.readFile('metoyou-attachment-files/server/room/files/missing.txt')).resolves.toBeNull();
await expect(store.getFileSize('metoyou-attachment-files/server/room/files/missing.txt')).resolves.toBeNull();
await expect(store.fileExists('metoyou-attachment-files/server/room/files/missing.txt')).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,228 @@
import { Injectable } from '@angular/core';
import { getStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { decodeBase64ToUint8Array, encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import type { AttachmentFileStore } from './attachment-file-store';
/** Synthetic app-data root used to namespace virtual attachment paths in the browser. */
const BROWSER_APP_DATA_ROOT = 'metoyou-attachment-files';
const DATABASE_PREFIX = 'metoyou-attachment-files';
const ANONYMOUS_SCOPE = 'anonymous';
const FILES_STORE = 'files';
const DATABASE_VERSION = 1;
interface StoredFileRecord {
path: string;
bytes: Uint8Array;
}
/**
* Attachment file store backed by a per-user IndexedDB "virtual filesystem".
*
* Bytes are keyed by the same synthetic path string the Electron/Capacitor
* stores use (`metoyou-attachment-files/server/<room>/<bucket>/<id>`), so the
* browser build remembers a user's own uploads and downloaded media across a
* reload/restart instead of holding them only in memory.
*/
@Injectable({ providedIn: 'root' })
export class BrowserAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES;
readonly supportsStreamingToDisk = false;
readonly supportsChunkedReads = true;
readonly providesInlineObjectUrl = false;
private database: IDBDatabase | null = null;
private activeDatabaseName: string | null = null;
get isAvailable(): boolean {
return typeof indexedDB !== 'undefined';
}
async getAppDataPath(): Promise<string | null> {
return this.isAvailable ? BROWSER_APP_DATA_ROOT : null;
}
async ensureDir(_dirPath: string): Promise<boolean> {
return this.isAvailable;
}
async writeFile(filePath: string, base64Data: string): Promise<boolean> {
if (!filePath) {
return false;
}
const bytes = base64Data ? decodeBase64ToUint8Array(base64Data) : new Uint8Array(0);
if (bytes.byteLength > this.maxPersistableBytes) {
return false;
}
return this.putRecord({ path: filePath, bytes });
}
async appendFile(filePath: string, base64Data: string): Promise<boolean> {
if (!filePath || !base64Data) {
return false;
}
const existing = await this.getRecord(filePath);
const addition = decodeBase64ToUint8Array(base64Data);
const previousBytes = existing?.bytes ?? new Uint8Array(0);
if (previousBytes.byteLength + addition.byteLength > this.maxPersistableBytes) {
return false;
}
const combined = new Uint8Array(previousBytes.byteLength + addition.byteLength);
combined.set(previousBytes, 0);
combined.set(addition, previousBytes.byteLength);
return this.putRecord({ path: filePath, bytes: combined });
}
async readFile(filePath: string): Promise<string | null> {
const record = await this.getRecord(filePath);
return record ? encodeUint8ArrayToBase64(record.bytes) : null;
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const record = await this.getRecord(filePath);
if (!record) {
return null;
}
return encodeUint8ArrayToBase64(record.bytes.subarray(start, end));
}
async getFileSize(filePath: string): Promise<number | null> {
const record = await this.getRecord(filePath);
return record ? record.bytes.byteLength : null;
}
async fileExists(filePath: string): Promise<boolean> {
return (await this.getRecord(filePath)) !== null;
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const record = await this.getRecord(sourceFilePath);
if (!record) {
return false;
}
return this.putRecord({ path: destinationFilePath, bytes: record.bytes });
}
async deleteFile(filePath: string): Promise<void> {
if (!filePath) {
return;
}
try {
const database = await this.openDatabase();
const transaction = database.transaction(FILES_STORE, 'readwrite');
transaction.objectStore(FILES_STORE).delete(filePath);
await this.awaitTransaction(transaction);
} catch { /* best-effort cleanup */ }
}
async getFileUrl(filePath: string): Promise<string | null> {
const record = await this.getRecord(filePath);
if (!record) {
return null;
}
try {
const blob = new Blob([record.bytes.buffer.slice(0) as ArrayBuffer]);
return URL.createObjectURL(blob);
} catch {
return null;
}
}
private async putRecord(record: StoredFileRecord): Promise<boolean> {
try {
const database = await this.openDatabase();
const transaction = database.transaction(FILES_STORE, 'readwrite');
transaction.objectStore(FILES_STORE).put(record);
await this.awaitTransaction(transaction);
return true;
} catch {
return false;
}
}
private async getRecord(path: string): Promise<StoredFileRecord | null> {
if (!path) {
return null;
}
try {
const database = await this.openDatabase();
const transaction = database.transaction(FILES_STORE, 'readonly');
const request = transaction.objectStore(FILES_STORE).get(path);
return await new Promise<StoredFileRecord | null>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as StoredFileRecord | undefined) ?? null);
request.onerror = () => reject(request.error);
});
} catch {
return null;
}
}
private async openDatabase(): Promise<IDBDatabase> {
const databaseName = this.resolveDatabaseName();
if (this.database && this.activeDatabaseName === databaseName) {
return this.database;
}
this.database?.close();
this.database = await this.openScopedDatabase(databaseName);
this.activeDatabaseName = databaseName;
return this.database;
}
private resolveDatabaseName(): string {
const userId = getStoredCurrentUserId();
return `${DATABASE_PREFIX}::${encodeURIComponent(userId || ANONYMOUS_SCOPE)}`;
}
private openScopedDatabase(databaseName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(databaseName, DATABASE_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(FILES_STORE)) {
database.createObjectStore(FILES_STORE, { keyPath: 'path' });
}
};
request.onsuccess = () => resolve(request.result);
});
}
private awaitTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
}

View File

@@ -0,0 +1,171 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
const isCapacitorNativeRuntimeMock = vi.fn(() => true);
const loadFilesystemMock = vi.fn();
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
}));
vi.mock('./capacitor-attachment-filesystem.adapter', () => ({
loadCapacitorAttachmentFilesystem: () => loadFilesystemMock()
}));
import { CapacitorAttachmentFileStore } from './capacitor-attachment-file-store';
function base64Of(text: string): string {
return encodeUint8ArrayToBase64(new TextEncoder().encode(text));
}
interface FakeFile {
data: string;
}
function createFakeFilesystem() {
const files = new Map<string, FakeFile>();
const filesystem = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async ({ path, data }: { path: string; data: string }) => {
files.set(path, { data });
return { uri: `file:///data/${path}` };
}),
appendFile: vi.fn(async ({ path, data }: { path: string; data: string }) => {
const existingText = atob(files.get(path)?.data ?? '');
const combinedText = existingText + atob(data);
const combinedBytes = Uint8Array.from(combinedText, (char) => char.charCodeAt(0));
files.set(path, { data: encodeUint8ArrayToBase64(combinedBytes) });
}),
readFile: vi.fn(async ({ path }: { path: string }) => {
const file = files.get(path);
if (!file) {
throw new Error('not found');
}
return { data: file.data };
}),
stat: vi.fn(async ({ path }: { path: string }) => {
const file = files.get(path);
if (!file) {
throw new Error('not found');
}
return { size: atob(file.data).length, uri: `file:///data/${path}` };
}),
deleteFile: vi.fn(async ({ path }: { path: string }) => {
files.delete(path);
}),
copy: vi.fn(async ({ from, to }: { from: string; to: string }) => {
const file = files.get(from);
if (!file) {
throw new Error('not found');
}
files.set(to, { data: file.data });
}),
getUri: vi.fn(async ({ path }: { path: string }) => ({ uri: `file:///data/${path}` }))
};
return {
files,
adapter: {
filesystem,
directory: 'DATA',
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
}
};
}
describe('CapacitorAttachmentFileStore', () => {
let store: CapacitorAttachmentFileStore;
let fake: ReturnType<typeof createFakeFilesystem>;
const path = 'metoyou/server/room/files/sample.bin';
beforeEach(() => {
isCapacitorNativeRuntimeMock.mockReturnValue(true);
fake = createFakeFilesystem();
loadFilesystemMock.mockResolvedValue(fake.adapter);
store = new CapacitorAttachmentFileStore();
});
it('reports native availability and the synthetic app-data root', async () => {
expect(store.isAvailable).toBe(true);
await expect(store.getAppDataPath()).resolves.toBe('metoyou');
});
it('is unavailable off a native shell', () => {
isCapacitorNativeRuntimeMock.mockReturnValue(false);
expect(store.isAvailable).toBe(false);
});
it('writes through the Data directory and reads bytes back', async () => {
await expect(store.writeFile(path, base64Of('hello'))).resolves.toBe(true);
expect(fake.adapter.filesystem.writeFile).toHaveBeenCalledWith(expect.objectContaining({
path,
directory: 'DATA',
recursive: true
}));
await expect(store.readFile(path)).resolves.toBe(base64Of('hello'));
await expect(store.getFileSize(path)).resolves.toBe(5);
await expect(store.fileExists(path)).resolves.toBe(true);
});
it('streams appended chunks to disk', async () => {
await store.writeFile(path, '');
await store.appendFile(path, base64Of('ab'));
await store.appendFile(path, base64Of('cde'));
expect(fake.adapter.filesystem.appendFile).toHaveBeenCalledTimes(2);
await expect(store.getFileSize(path)).resolves.toBe(5);
});
it('reads a byte range by slicing the whole file', async () => {
await store.writeFile(path, base64Of('abcdef'));
const chunk = await store.readFileChunk(path, 1, 4);
expect(chunk).toBe(base64Of('bcd'));
});
it('copies and deletes files', async () => {
const destination = 'metoyou/server/room/files/copy.bin';
await store.writeFile(path, base64Of('data'));
await expect(store.copyFile(path, destination)).resolves.toBe(true);
await expect(store.fileExists(destination)).resolves.toBe(true);
await store.deleteFile(path);
await expect(store.fileExists(path)).resolves.toBe(false);
});
it('returns a webview-loadable file URL', async () => {
await store.writeFile(path, base64Of('data'));
await expect(store.getFileUrl(path)).resolves.toBe(
`capacitor://localhost/_capacitor_file_/data/${path}`
);
});
it('degrades to unavailable behaviour when the plugin cannot load', async () => {
loadFilesystemMock.mockResolvedValue(null);
await expect(store.writeFile(path, base64Of('x'))).resolves.toBe(false);
await expect(store.fileExists(path)).resolves.toBe(false);
await expect(store.getFileUrl(path)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,203 @@
import { Injectable } from '@angular/core';
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
import { decodeBase64ToUint8Array, encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import type { AttachmentFileStore } from './attachment-file-store';
import { loadCapacitorAttachmentFilesystem, type CapacitorAttachmentFilesystem } from './capacitor-attachment-filesystem.adapter';
/**
* Synthetic app-data root for attachment paths on Capacitor. Stored files live
* under `Directory.Data/<root>/server/...`, so the path passed around the
* attachment domain stays consistent with the other backends and passes the
* `isAllowedAttachmentStoredPath` allow-list.
*/
const CAPACITOR_APP_DATA_ROOT = 'metoyou';
/** Attachment file store backed by the native Capacitor Filesystem (`Directory.Data`). */
@Injectable({ providedIn: 'root' })
export class CapacitorAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
readonly supportsStreamingToDisk = true;
readonly supportsChunkedReads = false;
readonly providesInlineObjectUrl = true;
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
get isAvailable(): boolean {
return isCapacitorNativeRuntime();
}
async getAppDataPath(): Promise<string | null> {
return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null;
}
async ensureDir(dirPath: string): Promise<boolean> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !dirPath) {
return false;
}
try {
await filesystem.filesystem.mkdir({
path: dirPath,
directory: filesystem.directory,
recursive: true
});
return true;
} catch {
// mkdir throws when the directory already exists; treat that as success.
return true;
}
}
async writeFile(filePath: string, base64Data: string): Promise<boolean> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath) {
return false;
}
try {
await filesystem.filesystem.writeFile({
path: filePath,
data: base64Data,
directory: filesystem.directory,
recursive: true
});
return true;
} catch {
return false;
}
}
async appendFile(filePath: string, base64Data: string): Promise<boolean> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath || !base64Data) {
return false;
}
try {
await filesystem.filesystem.appendFile({
path: filePath,
data: base64Data,
directory: filesystem.directory
});
return true;
} catch {
return false;
}
}
async readFile(filePath: string): Promise<string | null> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath) {
return null;
}
try {
const result = await filesystem.filesystem.readFile({
path: filePath,
directory: filesystem.directory
});
return typeof result.data === 'string'
? result.data
: encodeUint8ArrayToBase64(new Uint8Array(await result.data.arrayBuffer()));
} catch {
return null;
}
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const whole = await this.readFile(filePath);
if (whole === null) {
return null;
}
return encodeUint8ArrayToBase64(decodeBase64ToUint8Array(whole).subarray(start, end));
}
async getFileSize(filePath: string): Promise<number | null> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath) {
return null;
}
try {
const stat = await filesystem.filesystem.stat({
path: filePath,
directory: filesystem.directory
});
return stat.size;
} catch {
return null;
}
}
async fileExists(filePath: string): Promise<boolean> {
return (await this.getFileSize(filePath)) !== null;
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !sourceFilePath || !destinationFilePath) {
return false;
}
try {
await filesystem.filesystem.copy({
from: sourceFilePath,
to: destinationFilePath,
directory: filesystem.directory,
toDirectory: filesystem.directory
});
return true;
} catch {
return false;
}
}
async deleteFile(filePath: string): Promise<void> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath) {
return;
}
try {
await filesystem.filesystem.deleteFile({
path: filePath,
directory: filesystem.directory
});
} catch { /* best-effort cleanup */ }
}
async getFileUrl(filePath: string): Promise<string | null> {
const filesystem = await this.loadFilesystem();
if (!filesystem || !filePath) {
return null;
}
try {
const { uri } = await filesystem.filesystem.getUri({
path: filePath,
directory: filesystem.directory
});
return filesystem.convertFileSrc(uri);
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,50 @@
type CapacitorFilesystemModule = typeof import('@capacitor/filesystem');
type CapacitorCoreModule = typeof import('@capacitor/core');
/**
* Minimal Capacitor Filesystem surface the attachment store depends on, plus the
* `Directory` enum and `convertFileSrc` helper. Loaded lazily so the desktop and
* browser builds never statically import `@capacitor/*` (see LESSONS:
* "Lazy-load Capacitor modules on Electron/desktop").
*/
export interface CapacitorAttachmentFilesystem {
filesystem: CapacitorFilesystemModule['Filesystem'];
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
convertFileSrc: (url: string) => string;
}
let cachedFilesystem: Promise<CapacitorAttachmentFilesystem | null> | null = null;
/**
* Resolve the Capacitor Filesystem plugin (scoped to the app `Data` directory)
* on native shells. Returns `null` on web/Electron or when the plugin is
* unavailable.
*/
export function loadCapacitorAttachmentFilesystem(): Promise<CapacitorAttachmentFilesystem | null> {
cachedFilesystem ??= resolveCapacitorAttachmentFilesystem();
return cachedFilesystem;
}
async function resolveCapacitorAttachmentFilesystem(): Promise<CapacitorAttachmentFilesystem | null> {
if (typeof window === 'undefined') {
return null;
}
try {
const filesystemModule: CapacitorFilesystemModule = await import('@capacitor/filesystem');
const coreModule: CapacitorCoreModule = await import('@capacitor/core');
if (!coreModule.Capacitor.isNativePlatform()) {
return null;
}
return {
filesystem: filesystemModule.Filesystem,
directory: filesystemModule.Directory.Data,
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,172 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { AttachmentFileStore } from './attachment-file-store';
/** Attachment file store backed by the Electron main-process filesystem (IPC). */
@Injectable({ providedIn: 'root' })
export class ElectronAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
readonly supportsStreamingToDisk = true;
readonly supportsChunkedReads = true;
readonly providesInlineObjectUrl = false;
private readonly electronBridge = inject(ElectronBridgeService);
get isAvailable(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
}
async getAppDataPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await electronApi.getAppDataPath();
} catch {
return null;
}
}
async ensureDir(dirPath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.ensureDir || !dirPath) {
return false;
}
try {
return await electronApi.ensureDir(dirPath);
} catch {
return false;
}
}
async writeFile(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.writeFile || !filePath) {
return false;
}
try {
return await electronApi.writeFile(filePath, base64Data);
} catch {
return false;
}
}
async appendFile(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 readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return null;
}
try {
return await electronApi.readFile(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 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 fileExists(filePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.fileExists || !filePath) {
return false;
}
try {
return await electronApi.fileExists(filePath);
} catch {
return false;
}
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
return false;
}
try {
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
} catch {
return false;
}
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return;
}
try {
await electronApi.deleteFile(filePath);
} catch { /* best-effort cleanup */ }
}
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;
}
}
}