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

@@ -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 { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform/platform.service';
import type { Attachment } from '../../domain/models/attachment.model';
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import {
isAllowedAttachmentStoredPath,
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName
} 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:';
/**
* 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' })
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 {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
return this.store.isAvailable;
}
canReadFileChunks(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
return this.store.isAvailable && this.store.supportsChunkedReads;
}
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(
@@ -55,17 +79,11 @@ export class AttachmentStorageService {
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
if (!sourceFilePath || !destinationFilePath) {
return false;
}
try {
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
} catch {
return false;
}
return this.store.copyFile(sourceFilePath, destinationFilePath);
}
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
@@ -81,59 +99,35 @@ export class AttachmentStorageService {
}
async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
if (!filePath) {
return null;
}
try {
return await electronApi.readFile(filePath);
} catch {
return null;
}
return this.store.readFile(filePath);
}
async getFileSize(filePath: string): Promise<number | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileSize || !filePath) {
if (!filePath) {
return null;
}
try {
return await electronApi.getFileSize(filePath);
} catch {
return null;
}
return this.store.getFileSize(filePath);
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.readFileChunk || !filePath) {
if (!filePath) {
return null;
}
try {
return await electronApi.readFileChunk(filePath, start, end);
} catch {
return null;
}
return this.store.readFileChunk(filePath, start, end);
}
async getFileUrl(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileUrl || !filePath) {
if (!filePath) {
return null;
}
try {
return await electronApi.getFileUrl(filePath);
} catch {
return null;
}
return this.store.getFileUrl(filePath);
}
async saveBlob(
@@ -141,6 +135,10 @@ export class AttachmentStorageService {
blob: Blob,
roomName: string
): Promise<string | null> {
if (!this.canPersistSize(blob.size)) {
return null;
}
const diskPath = await this.createWritableFile(attachment, roomName);
if (!diskPath) {
@@ -149,10 +147,9 @@ export class AttachmentStorageService {
try {
const arrayBuffer = await blob.arrayBuffer();
const wrote = await this.store.writeFile(diskPath, encodeUint8ArrayToBase64(new Uint8Array(arrayBuffer)));
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
return wrote ? diskPath : null;
} catch {
return null;
}
@@ -162,20 +159,19 @@ export class AttachmentStorageService {
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) {
if (!appDataPath) {
return null;
}
try {
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)}`;
await this.writeBase64(diskPath, '');
await this.store.writeFile(diskPath, '');
return diskPath;
} catch {
@@ -184,43 +180,35 @@ export class AttachmentStorageService {
}
async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFile || !filePath) {
if (!filePath) {
return false;
}
try {
return await electronApi.appendFile(filePath, base64Data);
} catch {
return false;
}
return this.store.appendFile(filePath, base64Data);
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
if (!filePath) {
return;
}
try {
await electronApi.deleteFile(filePath);
} catch { /* best-effort cleanup */ }
await this.store.deleteFile(filePath);
}
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> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await electronApi.getAppDataPath();
} catch {
return null;
}
return this.store.getAppDataPath();
}
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> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) {
if (!appDataPath) {
return null;
}
@@ -249,7 +236,7 @@ export class AttachmentStorageService {
}
try {
if (await electronApi.fileExists(candidatePath)) {
if (await this.store.fileExists(candidatePath)) {
return candidatePath;
}
} catch { /* keep trying remaining candidates */ }
@@ -257,26 +244,4 @@ export class AttachmentStorageService {
return null;
}
private async writeBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return false;
}
return await electronApi.writeFile(filePath, base64Data);
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
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;
}
}
}