diff --git a/plugins/lib/src/helpers/downloadObject.ts b/plugins/lib/src/helpers/downloadObject.ts new file mode 100644 index 0000000..a19338e --- /dev/null +++ b/plugins/lib/src/helpers/downloadObject.ts @@ -0,0 +1,16 @@ +/** + * Download content as a file + */ +export const downloadObject = (content: string, filename: string, type: string) => +{ + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + + a.click(); + + URL.revokeObjectURL(url); +}; diff --git a/plugins/lib/src/helpers/index.ts b/plugins/lib/src/helpers/index.ts index b38e3d3..699ff68 100644 --- a/plugins/lib/src/helpers/index.ts +++ b/plugins/lib/src/helpers/index.ts @@ -4,3 +4,4 @@ export * from "./getPlaybackInfo.dasha.native"; export * from "./observable"; export * from "./parseDate"; export * from "./safeTimeout"; +export * from "./downloadObject"; diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx new file mode 100644 index 0000000..63301ee --- /dev/null +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { useConfirm } from "material-ui-confirm"; + +import Stack from "@mui/material/Stack"; +import FileDownloadIcon from "@mui/icons-material/FileDownload"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; + +import { Messager, SettingsTransfer, type ExportData } from "@luna/core"; +import { downloadObject, redux, Tidal } from "@luna/lib"; +import { relaunch } from "plugins/lib.native/src/index.native"; + +import { LunaButton, LunaSettings, LunaSwitchSetting } from "../../components"; + +export const LunaSettingsTransfer = React.memo(() => +{ + const confirm = useConfirm(); + const fileInputRef = React.useRef(null); + const [busy, setBusy] = React.useState(false); + const [stripCode, setStripCode] = React.useState(true); + + const onExport = React.useCallback(async () => + { + setBusy(true); + try + { + //feature flags + const featureFlags = redux.store.getState().featureFlags.userOverrides as Record;; + + const data = await SettingsTransfer.dump(stripCode, Object.keys(featureFlags).length > 0 ? featureFlags : null); + + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + downloadObject(JSON.stringify(data), `tidaluna-settings-${dateStr}.json`, "application/json"); + } + catch (err: any) + { + Messager.Error("Failed to export settings: ", err.message); + } + finally + { + setBusy(false); + } + }, [stripCode]); + + const onImportClick = React.useCallback(() => + { + fileInputRef.current?.click(); + }, []); + + const onFileSelected = React.useCallback(async (event: React.ChangeEvent) => + { + const file = event.target.files?.[0]; + if (!file) + return; + + event.target.value = ""; + + try + { + const text = await file.text(); + const data: ExportData = JSON.parse(text); + + if (!SettingsTransfer.validate(data)) + { + Messager.Error("Invalid settings file format"); + return; + } + + const result = await confirm({ + title: "Import Settings", + description: `Import settings exported on ${new Date(data.timestamp).toLocaleString()}? Existing settings will be cleared and replaced, then the app will restart.`, + confirmationText: "Import & Restart", + }); + if (!result.confirmed) + return; + + setBusy(true); + + //stores + await SettingsTransfer.restore(data); + + //feature flags + if (data.featureFlags != null) + { + const currentFlags = Tidal.featureFlags; + for (const [name, value] of Object.entries(data.featureFlags)) + { + if (name in currentFlags && currentFlags[name].value !== value) + redux.actions["featureFlags/TOGGLE_USER_OVERRIDE"]({ ...currentFlags[name], value }); + } + } + + Messager.Info("Settings imported successfully, restarting..."); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await relaunch(); + } + catch (err: any) + { + Messager.Error("Failed to import settings: ", err.message); + } + finally + { + setBusy(false); + } + }, []); + + return ( + + + } children="Export Settings" /> + } children="Import Settings" /> + + + setStripCode(!stripCode)} + /> + + ); +}); \ No newline at end of file diff --git a/plugins/ui/src/SettingsPage/SettingsTab/index.tsx b/plugins/ui/src/SettingsPage/SettingsTab/index.tsx index 2357f43..d66e6cc 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/index.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/index.tsx @@ -6,6 +6,7 @@ import { LunaSettings } from "../../components"; import { LunaPlugin } from "@luna/core"; import { LunaPluginSettings } from "../PluginsTab/LunaPluginSettings"; import { LunaFeatureFlags } from "./LunaFeatureFlags"; +import { LunaSettingsTransfer } from "./LunaSettingsTransfer"; import { LunaVersionInfo } from "./LunaVersionInfo"; export const SettingsTab = React.memo(() => { @@ -17,6 +18,7 @@ export const SettingsTab = React.memo(() => { return ( + {corePlugins} diff --git a/render/src/LunaPlugin.ts b/render/src/LunaPlugin.ts index 918af4c..966152f 100644 --- a/render/src/LunaPlugin.ts +++ b/render/src/LunaPlugin.ts @@ -168,6 +168,25 @@ export class LunaPlugin { const keys = await LunaPlugin.pluginStorage.keys(); return Promise.all(keys.map(async (name) => LunaPlugin.fromName(name).catch(this.trace.err.withContext("loadStoredPlugins", name)))); } + + /** + * Dump all plugin storage entries + */ + public static async dumpStorage(stripCode: boolean = true): Promise> + { + const data = await this.pluginStorage.dump(); + if (!stripCode) + return data; + + const stripped = structuredClone(data); + for (const value of Object.values(stripped)) + { + if (value && typeof value === "object") + delete (value as any).package?.code; + } + + return stripped; + } // #endregion // #region Tracer diff --git a/render/src/ReactiveStore.ts b/render/src/ReactiveStore.ts index 4ccb482..5eb562d 100644 --- a/render/src/ReactiveStore.ts +++ b/render/src/ReactiveStore.ts @@ -79,4 +79,21 @@ export class ReactiveStore { public keys(): Promise { return idbKeys(this.idbStore); } + + public async dump(): Promise> + { + const allKeys = await this.keys(); + const data: Record = {}; + for (const key of allKeys) + data[key] = await this.get(key); + + return data; + } + + public async clear() + { + const allKeys = await this.keys(); + for (const key of allKeys) + await this.del(key); + } } diff --git a/render/src/SettingsTransfer.ts b/render/src/SettingsTransfer.ts new file mode 100644 index 0000000..242e0a4 --- /dev/null +++ b/render/src/SettingsTransfer.ts @@ -0,0 +1,70 @@ +import { ReactiveStore } from "./ReactiveStore"; +import { LunaPlugin } from "./LunaPlugin"; + +export interface ExportData +{ + version: 1; //future proofing -> if anything changes we want the ability to load old exports correctly + timestamp: string; + stores: Record>; + featureFlags: Record | null; +} + +export class SettingsTransfer +{ + //new stores to be added here + private static readonly exportableStores: ReactiveStore[] = + [ + ReactiveStore.getStore("@luna/pluginStorage"), + LunaPlugin.pluginStorage, //@luna/plugins + ReactiveStore.getStore("@luna/storage"), + ]; + + public static async dump(stripCode: boolean = true, featureFlags: Record | null = null): Promise + { + const stores: Record> = {}; + for (const store of this.exportableStores) + { + if (store === LunaPlugin.pluginStorage) + stores[store.idbName] = await LunaPlugin.dumpStorage(stripCode); + else + stores[store.idbName] = await store.dump(); + } + + return { + version: 1, + timestamp: new Date().toISOString(), + stores, + featureFlags, + }; + } + + public static async restore(data: ExportData) + { + for (const store of this.exportableStores) + { + const storeData = data.stores[store.idbName]; + if (!storeData) + continue; + + await store.clear(); + + for (const [key, value] of Object.entries(storeData)) + await store.set(key, value); + } + } + + public static validate(data: unknown): data is ExportData + { + if (typeof data !== "object" || data === null) + return false; + + const obj = data as Record; + if (obj.version !== 1) + return false; + + if (typeof obj.stores !== "object" || obj.stores === null) + return false; + + return true; + } +} diff --git a/render/src/index.ts b/render/src/index.ts index ec344cd..eb32dca 100644 --- a/render/src/index.ts +++ b/render/src/index.ts @@ -12,6 +12,7 @@ export { modules, reduxStore } from "./modules"; export * from "./LunaPlugin"; export * from "./ReactiveStore"; +export * from "./SettingsTransfer"; // Ensure this is loaded import "./window.core";