import * as React from "react"; import { type Storage as StorageSchema, storageDefaults as defaults, } from "@tensamin/shared/data"; import { deleteDatabaseEntry, getDatabaseEntry, setDatabaseEntry, } from "@tensamin/shared/indexedDb"; import { ErrorScreen } from "@tensamin/ui"; import { log } from "@tensamin/shared/log"; import { decodeSecureValue, encodeSecureValue, getSecureStorageStatus, isSecureEnvelope, type SecureStorageStatus, } from "./secure"; export type SaveOptions = { secure?: boolean }; interface StorageContextValue { load(key: K): Promise; save( key: K, value: StorageSchema[K], options?: SaveOptions, ): Promise; clear: () => Promise; secureStorage: SecureStorageStatus | null; } const StorageContext = React.createContext( undefined, ); const isIndexedDBSupported = typeof indexedDB !== "undefined"; /** * Executes StorageProvider. * @param props Parameter props. * @returns unknown. */ export default function StorageProvider(props: { children: React.ReactNode }) { const [storage, setStorage] = React.useState(defaults); const storageRef = React.useRef(storage); const loadedKeys = React.useRef(new Set()); const loadPromises = React.useRef( new Map>(), ); const generations = React.useRef(new Map()); const [secureStorage, setSecureStorage] = React.useState(null); const secureStorageRef = React.useRef(null); const [error, setError] = React.useState(""); const [errorDescription, setErrorDescription] = React.useState(""); React.useEffect(() => { void getSecureStorageStatus().then((status) => { secureStorageRef.current = status; setSecureStorage(status); }); }, []); const commit = React.useCallback( (key: K, nextValue: StorageSchema[K]) => { const next = { ...storageRef.current, [key]: nextValue }; storageRef.current = next; loadedKeys.current.add(key); setStorage(next); }, [], ); const load = React.useCallback( async ( key: K, ): Promise => { if (loadedKeys.current.has(key)) return storageRef.current[key]; const pending = loadPromises.current.get(key); if (pending) return pending as Promise; const generation = generations.current.get(key) ?? 0; const request = (async () => { try { const desktopStorage = window.tensaminDesktop?.secureStorage; const desktopStatus = desktopStorage?.getStatus ? await desktopStorage.getStatus() : null; const desktopValue = desktopStatus?.available && desktopStorage?.load ? await desktopStorage.load(String(key)) : null; let stored = desktopValue === null ? await getDatabaseEntry("storage", key) : (JSON.parse(desktopValue) as StorageSchema[K]); if (isSecureEnvelope(stored)) { stored = (await decodeSecureValue(stored)) as StorageSchema[K]; } const value = stored ?? defaults[key]; if ((generations.current.get(key) ?? 0) === generation) { commit(key, value); } return (generations.current.get(key) ?? 0) === generation ? value : storageRef.current[key]; } catch (err) { setError("Failed to load data"); setErrorDescription( "An error occurred while loading local data. Please reload and try again.", ); log(0, "Storage", "red", err); throw err; } finally { loadPromises.current.delete(key); } })(); loadPromises.current.set( key, request as Promise, ); return request; }, [commit], ); const save = React.useCallback( async ( key: K, value: StorageSchema[K], options: SaveOptions = {}, ): Promise => { generations.current.set(key, (generations.current.get(key) ?? 0) + 1); const desktopStorage = window.tensaminDesktop?.secureStorage; if (JSON.stringify(value) === JSON.stringify(defaults[key])) { if (desktopStorage?.delete) await desktopStorage.delete(String(key)).catch(() => undefined); await deleteDatabaseEntry("storage", key); commit(key, defaults[key]); } else { const status = options.secure ? (secureStorageRef.current ?? (await getSecureStorageStatus())) : secureStorageRef.current; if ( options.secure && status?.backend === "electron-keyring" && desktopStorage?.save ) { await desktopStorage.save(String(key), JSON.stringify(value)); await deleteDatabaseEntry("storage", key); } else { const persisted = options.secure ? await encodeSecureValue(value) : value; await setDatabaseEntry("storage", key, persisted as StorageSchema[K]); } commit(key, value); } }, [commit], ); const clear = React.useCallback(async () => { const keys = Object.keys(defaults) as (keyof StorageSchema)[]; for (const key of keys) { generations.current.set(key, (generations.current.get(key) ?? 0) + 1); } await Promise.all( keys.map((key) => deleteDatabaseEntry("storage", key)), ); await window.tensaminDesktop?.secureStorage ?.clear?.() .catch(() => undefined); loadedKeys.current.clear(); loadPromises.current.clear(); storageRef.current = defaults; setStorage(defaults); }, []); const value = React.useMemo( () => ({ load, save, clear, secureStorage, }), [clear, load, save, secureStorage], ); if (error !== "" && errorDescription !== "") { return ; } if (!isIndexedDBSupported) { return ( ); } return ( {props.children} ); } /** * Executes useStorage. * @param none This function has no parameters. * @returns StorageContextValue. */ export function useStorage(): StorageContextValue { const context = React.useContext(StorageContext); if (!context) { throw new Error("useStorage must be used within a StorageProvider"); } return context; }