From 054f063f1f77442c7091d1ff4b7bed7bd2e2245d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:03:01 +0200 Subject: [PATCH] feat(web): IDB persistence + capability detection for FileSystemFileHandle --- packages/web/src/utils/idbHandles.test.ts | 44 +++++++++++++ packages/web/src/utils/idbHandles.ts | 76 +++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/web/src/utils/idbHandles.test.ts create mode 100644 packages/web/src/utils/idbHandles.ts diff --git a/packages/web/src/utils/idbHandles.test.ts b/packages/web/src/utils/idbHandles.test.ts new file mode 100644 index 00000000..84b64079 --- /dev/null +++ b/packages/web/src/utils/idbHandles.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { putHandle, getHandle, deleteHandle, clearAllHandles, supportsFsHandles, supportsDnDHandles } from './idbHandles'; + +describe('idbHandles', () => { + beforeEach(async () => { await clearAllHandles(); }); + + it('round-trips a structured-cloneable handle stand-in', async () => { + const fakeHandle = { kind: 'file', name: 'photo.png' } as unknown as FileSystemHandle; + await putHandle('t-1', fakeHandle); + const read = await getHandle('t-1'); + expect(read).toEqual(fakeHandle); + }); + + it('returns undefined for missing key', async () => { + expect(await getHandle('t-missing')).toBeUndefined(); + }); + + it('deletes a stored handle', async () => { + await putHandle('t-2', { kind: 'file' } as unknown as FileSystemHandle); + await deleteHandle('t-2'); + expect(await getHandle('t-2')).toBeUndefined(); + }); + + it('deleteHandle on a missing key is a no-op (no throw)', async () => { + await expect(deleteHandle('t-never')).resolves.toBeUndefined(); + }); + + it('clearAllHandles empties the store', async () => { + await putHandle('t-3', { kind: 'file' } as unknown as FileSystemHandle); + await putHandle('t-4', { kind: 'file' } as unknown as FileSystemHandle); + await clearAllHandles(); + expect(await getHandle('t-3')).toBeUndefined(); + expect(await getHandle('t-4')).toBeUndefined(); + }); + + it('supportsFsHandles returns a boolean', () => { + expect(typeof supportsFsHandles()).toBe('boolean'); + }); + + it('supportsDnDHandles returns a boolean', () => { + expect(typeof supportsDnDHandles()).toBe('boolean'); + }); +}); diff --git a/packages/web/src/utils/idbHandles.ts b/packages/web/src/utils/idbHandles.ts new file mode 100644 index 00000000..4018c042 --- /dev/null +++ b/packages/web/src/utils/idbHandles.ts @@ -0,0 +1,76 @@ +import { openDB, type IDBPDatabase } from 'idb'; + +const DB_NAME = 'backspace-transfers'; +const STORE = 'fs-handles'; +const VERSION = 1; + +let dbPromise: Promise | null = null; + +function getDB(): Promise { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }, + }); + } + return dbPromise; +} + +/** Store a FileSystemFileHandle (or any structured-cloneable handle) under a key. */ +export async function putHandle(key: string, handle: FileSystemHandle): Promise { + const db = await getDB(); + await db.put(STORE, handle, key); +} + +/** Retrieve a stored handle, or undefined if not present. */ +export async function getHandle(key: string): Promise { + const db = await getDB(); + return db.get(STORE, key); +} + +/** Delete a stored handle. No-op if missing. */ +export async function deleteHandle(key: string): Promise { + const db = await getDB(); + await db.delete(STORE, key); +} + +/** Wipe every handle from the store. Used in tests and on logout. */ +export async function clearAllHandles(): Promise { + const db = await getDB(); + await db.clear(STORE); +} + +/** True iff the browser exposes the FS Access file picker (Chrome/Edge). */ +export function supportsFsHandles(): boolean { + return typeof (window as unknown as { showOpenFilePicker?: unknown }).showOpenFilePicker === 'function'; +} + +/** True iff DataTransferItem.getAsFileSystemHandle is available (Chrome/Edge drag-drop). */ +export function supportsDnDHandles(): boolean { + return typeof DataTransferItem !== 'undefined' + && typeof (DataTransferItem.prototype as unknown as { getAsFileSystemHandle?: unknown }).getAsFileSystemHandle === 'function'; +} + +/** + * Re-prompt for permission on a stored handle. Returns 'granted', 'denied', or 'prompt'. + * Some non-standard FS Access surfaces don't expose `queryPermission`/`requestPermission` — + * if missing, returns 'denied' so callers fall back to re-pick. + */ +export async function ensurePermission( + handle: FileSystemHandle, + mode: 'read' | 'readwrite', +): Promise { + const opts = { mode }; + const handleAny = handle as unknown as { + queryPermission?: (opts: { mode: string }) => Promise; + requestPermission?: (opts: { mode: string }) => Promise; + }; + if (typeof handleAny.queryPermission !== 'function') return 'denied'; + const current = await handleAny.queryPermission(opts); + if (current === 'granted') return 'granted'; + if (typeof handleAny.requestPermission !== 'function') return 'denied'; + return handleAny.requestPermission(opts); +}