All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
244 lines
7.4 KiB
TypeScript
244 lines
7.4 KiB
TypeScript
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} 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 "@methanium/ui";
|
|
import { log } from "@tensamin/shared/log";
|
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
|
import {
|
|
decodeSecureValue,
|
|
encodeSecureValue,
|
|
getSecureStorageStatus,
|
|
isSecureEnvelope,
|
|
type SecureStorageStatus,
|
|
} from "./secure";
|
|
|
|
export type SaveOptions = { secure?: boolean };
|
|
|
|
export interface StorageContextValue {
|
|
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
|
save<K extends keyof StorageSchema>(
|
|
key: K,
|
|
value: StorageSchema[K],
|
|
options?: SaveOptions,
|
|
): Promise<void>;
|
|
clear: () => Promise<void>;
|
|
secureStorage: SecureStorageStatus | null;
|
|
}
|
|
|
|
const StorageContext = createContext<StorageContextValue | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const isIndexedDBSupported = typeof indexedDB !== "undefined";
|
|
|
|
/**
|
|
* Executes StorageProvider.
|
|
* @param props Parameter props.
|
|
* @returns unknown.
|
|
*/
|
|
export default function StorageProvider(props: { children: ReactNode }) {
|
|
const [storage, setStorage] = useState<StorageSchema>(defaults);
|
|
const storageRef = useRef(storage);
|
|
const loadedKeys = useRef(new Set<keyof StorageSchema>());
|
|
const loadPromises = useRef(
|
|
new Map<keyof StorageSchema, Promise<StorageSchema[keyof StorageSchema]>>(),
|
|
);
|
|
const generations = useRef(new Map<keyof StorageSchema, number>());
|
|
const [secureStorage, setSecureStorage] =
|
|
useState<SecureStorageStatus | null>(null);
|
|
const secureStorageRef = useRef<SecureStorageStatus | null>(null);
|
|
|
|
const [error, setError] = useState("");
|
|
const [errorDescription, setErrorDescription] = useState("");
|
|
|
|
useEffect(() => {
|
|
void getSecureStorageStatus().then((status) => {
|
|
secureStorageRef.current = status;
|
|
setSecureStorage(status);
|
|
});
|
|
}, []);
|
|
|
|
const commit = useCallback(
|
|
<K extends keyof StorageSchema>(key: K, nextValue: StorageSchema[K]) => {
|
|
const next = { ...storageRef.current, [key]: nextValue };
|
|
storageRef.current = next;
|
|
loadedKeys.current.add(key);
|
|
setStorage(next);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const load = useCallback(
|
|
async <K extends keyof StorageSchema>(
|
|
key: K,
|
|
): Promise<StorageSchema[K]> => {
|
|
if (loadedKeys.current.has(key)) return storageRef.current[key];
|
|
const pending = loadPromises.current.get(key);
|
|
if (pending) return pending as Promise<StorageSchema[K]>;
|
|
|
|
const generation = generations.current.get(key) ?? 0;
|
|
const request = (async () => {
|
|
try {
|
|
if (key === "mtp_keyring" && isTauri()) {
|
|
const nativeValue = await invoke<string | null>("mtp_load_keyring");
|
|
const value = (nativeValue ?? defaults[key]) as StorageSchema[K];
|
|
if ((generations.current.get(key) ?? 0) === generation) {
|
|
commit(key, value);
|
|
}
|
|
return value;
|
|
}
|
|
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<StorageSchema[K]>("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<StorageSchema[keyof StorageSchema]>,
|
|
);
|
|
return request;
|
|
},
|
|
[commit],
|
|
);
|
|
|
|
const save = useCallback(
|
|
async <K extends keyof StorageSchema>(
|
|
key: K,
|
|
value: StorageSchema[K],
|
|
options: SaveOptions = {},
|
|
): Promise<void> => {
|
|
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
|
if (key === "mtp_keyring" && isTauri()) {
|
|
commit(key, value);
|
|
return;
|
|
}
|
|
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 = 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 = useMemo<StorageContextValue>(
|
|
() => ({
|
|
load,
|
|
save,
|
|
clear,
|
|
secureStorage,
|
|
}),
|
|
[clear, load, save, secureStorage],
|
|
);
|
|
|
|
if (error !== "" && errorDescription !== "") {
|
|
return <ErrorScreen error={error} description={errorDescription} />;
|
|
}
|
|
|
|
if (!isIndexedDBSupported) {
|
|
return (
|
|
<ErrorScreen
|
|
error="Unsupported Browser"
|
|
description="Your browser does not support IndexedDB, which is required for this application to function."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<StorageContext.Provider value={value}>
|
|
{props.children}
|
|
</StorageContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Executes useStorage.
|
|
* @param none This function has no parameters.
|
|
* @returns StorageContextValue.
|
|
*/
|
|
export function useStorage(): StorageContextValue {
|
|
const context = useContext(StorageContext);
|
|
if (!context) {
|
|
throw new Error("useStorage must be used within a StorageProvider");
|
|
}
|
|
return context;
|
|
}
|