fix: Bug - Local files should be remembered by client
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user