feat(web): IDB persistence + capability detection for FileSystemFileHandle
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IDBPDatabase> | null = null;
|
||||||
|
|
||||||
|
function getDB(): Promise<IDBPDatabase> {
|
||||||
|
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<void> {
|
||||||
|
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<FileSystemHandle | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.get(STORE, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a stored handle. No-op if missing. */
|
||||||
|
export async function deleteHandle(key: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<PermissionState> {
|
||||||
|
const opts = { mode };
|
||||||
|
const handleAny = handle as unknown as {
|
||||||
|
queryPermission?: (opts: { mode: string }) => Promise<PermissionState>;
|
||||||
|
requestPermission?: (opts: { mode: string }) => Promise<PermissionState>;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user