From 0c8677f0b2181e6903a8519ef507fb95dccdfb9b Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:34:04 +0100 Subject: [PATCH 1/6] Settings export/import --- .../SettingsTab/LunaSettingsTransfer.tsx | 309 ++++++++++++++++++ .../ui/src/SettingsPage/SettingsTab/index.tsx | 2 + 2 files changed, 311 insertions(+) create mode 100644 plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx new file mode 100644 index 0000000..3d039b1 --- /dev/null +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -0,0 +1,309 @@ +import React from "react"; +import { useConfirm } from "material-ui-confirm"; + +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import FileDownloadIcon from "@mui/icons-material/FileDownload"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; + +import { Messager } from "@luna/core"; +import { relaunch } from "plugins/lib.native/src/index.native"; + +import { LunaButton, LunaSettings } from "../../components"; + +interface ExportData +{ + version: 1; //future proofing -> if anything changes we want the ability to load old exports correctly + timestamp: string; + pluginSettings: Record; //@luna/pluginStorage + installedPlugins: Record; //@luna/plugins (only installed) + themes: unknown; //@luna/storage themes key + featureFlags: unknown; //_TIDAL_featureFlags from localforage +} + +const openDatabase = (name: string): Promise => + new Promise((resolve, reject) => + { + const req = indexedDB.open(name); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +const readAllFromStore = (db: IDBDatabase, storeName: string): Promise> => + new Promise((resolve, reject) => + { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const req = store.getAll(); + const keyReq = store.getAllKeys(); + + const data: Record = {}; + tx.oncomplete = () => + { + for (let i = 0; i < keyReq.result.length; i++) + data[String(keyReq.result[i])] = req.result[i]; + + resolve(data); + }; + tx.onerror = () => reject(tx.error); + }); + +const readKeyFromStore = (db: IDBDatabase, storeName: string, key: string): Promise => + new Promise((resolve, reject) => + { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + + const req = store.get(key); + + tx.oncomplete = () => resolve(req.result); + tx.onerror = () => reject(tx.error); + }); + +const writeToStore = (db: IDBDatabase, storeName: string, key: string, value: unknown): Promise => + new Promise((resolve, reject) => + { + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + + store.put(value, key); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + +const exportSettings = async (): Promise => +{ + //plugins settings + const pluginStorageDb = await openDatabase("@luna/pluginStorage"); + const pluginSettings = await readAllFromStore(pluginStorageDb, "_"); + pluginStorageDb.close(); + + //installed plugins + const pluginsDb = await openDatabase("@luna/plugins"); + const allPlugins = await readAllFromStore(pluginsDb, "_"); + pluginsDb.close(); + + const installedPlugins: Record = {}; + for (const [key, value] of Object.entries(allPlugins)) + if (value && typeof value === "object" && (value as any).installed === true) + installedPlugins[key] = value; + + //themes + let themes: unknown = null; + try + { + const storageDb = await openDatabase("@luna/storage"); + themes = await readKeyFromStore(storageDb, "_", "themes"); + storageDb.close(); + } + catch (err) + { + console.warn("[SettingsTransfer] Could not read themes from @luna/storage: ", err); + } + + //feature flags + let featureFlags: unknown = null; + try + { + const localforageDb = await openDatabase("localforage"); + featureFlags = await readKeyFromStore(localforageDb, "keyvaluepairs", "_TIDAL_featureFlags"); + localforageDb.close(); + } + catch (err) + { + console.warn("[SettingsTransfer] Could not read feature flags from localforage: ", err); + } + + return { + version: 1, + timestamp: new Date().toISOString(), + pluginSettings, + installedPlugins, + themes, + featureFlags, + }; +}; + +const downloadJson = (data: ExportData) => +{ + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + + a.href = url; + a.download = `tidaluna-settings-${dateStr}.json`; + + a.click(); + + URL.revokeObjectURL(url); +}; + +const importSettings = async (data: ExportData) => +{ + //plugins settings + if (data.pluginSettings) + { + const db = await openDatabase("@luna/pluginStorage"); + for (const [key, value] of Object.entries(data.pluginSettings)) + await writeToStore(db, "_", key, value); + db.close(); + } + + //installed plugins + if (data.installedPlugins) + { + const db = await openDatabase("@luna/plugins"); + for (const [key, value] of Object.entries(data.installedPlugins)) + await writeToStore(db, "_", key, value); + db.close(); + } + + //import themes + if (data.themes != null) + { + try + { + const db = await openDatabase("@luna/storage"); + await writeToStore(db, "_", "themes", data.themes); + db.close(); + } + catch (err) + { + console.error("[SettingsTransfer] Failed to import themes: ", err); + } + } + + //feature flags + if (data.featureFlags != null) + { + try + { + const db = await openDatabase("localforage"); + await writeToStore(db, "keyvaluepairs", "_TIDAL_featureFlags", data.featureFlags); + db.close(); + } + catch (err) + { + console.error("[SettingsTransfer] Failed to import feature flags: ", err); + } + } +}; + +const validateImport = (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.pluginSettings !== "object" && typeof obj.installedPlugins !== "object" && obj.featureFlags === undefined) + return false; + + return true; +}; + +export const LunaSettingsTransfer = React.memo(() => +{ + const confirm = useConfirm(); + const fileInputRef = React.useRef(null); + const [busy, setBusy] = React.useState(false); + + const onExport = React.useCallback(async () => + { + setBusy(true); + try + { + const data = await exportSettings(); + + downloadJson(data); + + const pluginCount = Object.keys(data.pluginSettings).length; + const installedCount = Object.keys(data.installedPlugins).length; + const themeCount = data.themes && typeof data.themes === "object" ? Object.keys(data.themes).length : 0; + const hasFlags = data.featureFlags != null; + Messager.Info(`Exported ${installedCount} installed plugins, ${pluginCount} plugin settings, ${themeCount} themes${hasFlags ? " and feature flags" : ""}`); + } + catch (err: any) + { + Messager.Error("Failed to export settings: ", err.message); + } + finally + { + setBusy(false); + } + }, []); + + 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 = JSON.parse(text); + + if (!validateImport(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 overwritten and the app will restart.`, + confirmationText: "Import & Restart", + }); + if (!result.confirmed) + return; + + setBusy(true); + + await importSettings(data); + + Messager.Info("Settings imported successfully, restarting..."); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await relaunch(); + } + catch (err: any) + { + if (err === undefined) //confirm dialog cancelled + return; + + Messager.Error("Failed to import settings: ", err.message); + } + finally + { + setBusy(false); + } + }, []); + + return ( + + + } children="Export Settings" /> + } children="Import Settings" /> + + + + Exports installed plugins, plugin settings and feature flags. Import restores them and restarts the app. + + + ); +}); \ 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} From b12eefe5edbf2ad1eabd9380ddbdbf4fac28ee0b Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:59:18 +0100 Subject: [PATCH 2/6] Settings import/export - replace raw idb calls with reactivestores and Tidal.featureFlags --- .../SettingsTab/LunaSettingsTransfer.tsx | 132 ++++-------------- 1 file changed, 31 insertions(+), 101 deletions(-) diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx index 3d039b1..98f65f0 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -6,7 +6,8 @@ import Typography from "@mui/material/Typography"; import FileDownloadIcon from "@mui/icons-material/FileDownload"; import FileUploadIcon from "@mui/icons-material/FileUpload"; -import { Messager } from "@luna/core"; +import { LunaPlugin, Messager, ReactiveStore } from "@luna/core"; +import { redux, Tidal } from "@luna/lib"; import { relaunch } from "plugins/lib.native/src/index.native"; import { LunaButton, LunaSettings } from "../../components"; @@ -16,74 +17,31 @@ interface ExportData version: 1; //future proofing -> if anything changes we want the ability to load old exports correctly timestamp: string; pluginSettings: Record; //@luna/pluginStorage - installedPlugins: Record; //@luna/plugins (only installed) + installedPlugins: Record; //@luna/plugins themes: unknown; //@luna/storage themes key - featureFlags: unknown; //_TIDAL_featureFlags from localforage + featureFlags: Record | null; //Tidal.featureFlags (name -> value map) } -const openDatabase = (name: string): Promise => - new Promise((resolve, reject) => - { - const req = indexedDB.open(name); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - -const readAllFromStore = (db: IDBDatabase, storeName: string): Promise> => - new Promise((resolve, reject) => - { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const req = store.getAll(); - const keyReq = store.getAllKeys(); - - const data: Record = {}; - tx.oncomplete = () => - { - for (let i = 0; i < keyReq.result.length; i++) - data[String(keyReq.result[i])] = req.result[i]; - - resolve(data); - }; - tx.onerror = () => reject(tx.error); - }); - -const readKeyFromStore = (db: IDBDatabase, storeName: string, key: string): Promise => - new Promise((resolve, reject) => - { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - - const req = store.get(key); +const pluginSettingsStore = ReactiveStore.getStore("@luna/pluginStorage"); +const lunaStorage = ReactiveStore.getStore("@luna/storage"); - tx.oncomplete = () => resolve(req.result); - tx.onerror = () => reject(tx.error); - }); - -const writeToStore = (db: IDBDatabase, storeName: string, key: string, value: unknown): Promise => - new Promise((resolve, reject) => - { - const tx = db.transaction(storeName, "readwrite"); - const store = tx.objectStore(storeName); - - store.put(value, key); +const readAllFromStore = async (store: ReactiveStore): Promise> => +{ + const keys = await store.keys(); + const data: Record = {}; + for (const key of keys) + data[key] = await store.get(key); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); + return data; +}; const exportSettings = async (): Promise => { - //plugins settings - const pluginStorageDb = await openDatabase("@luna/pluginStorage"); - const pluginSettings = await readAllFromStore(pluginStorageDb, "_"); - pluginStorageDb.close(); + //plugin settings + const pluginSettings = await readAllFromStore(pluginSettingsStore); //installed plugins - const pluginsDb = await openDatabase("@luna/plugins"); - const allPlugins = await readAllFromStore(pluginsDb, "_"); - pluginsDb.close(); - + const allPlugins = await readAllFromStore(LunaPlugin.pluginStorage); const installedPlugins: Record = {}; for (const [key, value] of Object.entries(allPlugins)) if (value && typeof value === "object" && (value as any).installed === true) @@ -93,9 +51,7 @@ const exportSettings = async (): Promise => let themes: unknown = null; try { - const storageDb = await openDatabase("@luna/storage"); - themes = await readKeyFromStore(storageDb, "_", "themes"); - storageDb.close(); + themes = await lunaStorage.get("themes"); } catch (err) { @@ -103,17 +59,10 @@ const exportSettings = async (): Promise => } //feature flags - let featureFlags: unknown = null; - try - { - const localforageDb = await openDatabase("localforage"); - featureFlags = await readKeyFromStore(localforageDb, "keyvaluepairs", "_TIDAL_featureFlags"); - localforageDb.close(); - } - catch (err) - { - console.warn("[SettingsTransfer] Could not read feature flags from localforage: ", err); - } + const tidalFlags = Tidal.featureFlags; + const featureFlags: Record = {}; + for (const [name, flag] of Object.entries(tidalFlags)) + featureFlags[name] = flag.value; return { version: 1, @@ -145,32 +94,22 @@ const downloadJson = (data: ExportData) => const importSettings = async (data: ExportData) => { - //plugins settings + //plugin settings if (data.pluginSettings) - { - const db = await openDatabase("@luna/pluginStorage"); for (const [key, value] of Object.entries(data.pluginSettings)) - await writeToStore(db, "_", key, value); - db.close(); - } + await pluginSettingsStore.set(key, value); //installed plugins if (data.installedPlugins) - { - const db = await openDatabase("@luna/plugins"); for (const [key, value] of Object.entries(data.installedPlugins)) - await writeToStore(db, "_", key, value); - db.close(); - } + await LunaPlugin.pluginStorage.set(key, value); - //import themes + //themes if (data.themes != null) { try { - const db = await openDatabase("@luna/storage"); - await writeToStore(db, "_", "themes", data.themes); - db.close(); + await lunaStorage.set("themes", data.themes); } catch (err) { @@ -181,16 +120,10 @@ const importSettings = async (data: ExportData) => //feature flags if (data.featureFlags != null) { - try - { - const db = await openDatabase("localforage"); - await writeToStore(db, "keyvaluepairs", "_TIDAL_featureFlags", data.featureFlags); - db.close(); - } - catch (err) - { - console.error("[SettingsTransfer] Failed to import feature flags: ", err); - } + 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 }); } }; @@ -283,9 +216,6 @@ export const LunaSettingsTransfer = React.memo(() => } catch (err: any) { - if (err === undefined) //confirm dialog cancelled - return; - Messager.Error("Failed to import settings: ", err.message); } finally From 1477a19c72bd0085503db074726235242e0f4043 Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:55:41 +0100 Subject: [PATCH 3/6] Settings export/import - more improvements code stripping full storage dump clear stores before importing export only enabled feature flags dump plugin storage in LunaPlugin dump and clear methods in ReactiveStore downloadObject helper function --- plugins/lib/src/helpers/downloadObject.ts | 16 ++ plugins/lib/src/helpers/index.ts | 1 + .../SettingsTab/LunaSettingsTransfer.tsx | 185 ++++-------------- render/src/LunaPlugin.ts | 17 ++ render/src/ReactiveStore.ts | 17 ++ render/src/SettingsTransfer.ts | 70 +++++++ render/src/index.ts | 1 + 7 files changed, 157 insertions(+), 150 deletions(-) create mode 100644 plugins/lib/src/helpers/downloadObject.ts create mode 100644 render/src/SettingsTransfer.ts 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 index 98f65f0..b7b7f16 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -2,166 +2,38 @@ import React from "react"; import { useConfirm } from "material-ui-confirm"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import FileDownloadIcon from "@mui/icons-material/FileDownload"; import FileUploadIcon from "@mui/icons-material/FileUpload"; -import { LunaPlugin, Messager, ReactiveStore } from "@luna/core"; -import { redux, Tidal } from "@luna/lib"; +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 } from "../../components"; - -interface ExportData -{ - version: 1; //future proofing -> if anything changes we want the ability to load old exports correctly - timestamp: string; - pluginSettings: Record; //@luna/pluginStorage - installedPlugins: Record; //@luna/plugins - themes: unknown; //@luna/storage themes key - featureFlags: Record | null; //Tidal.featureFlags (name -> value map) -} - -const pluginSettingsStore = ReactiveStore.getStore("@luna/pluginStorage"); -const lunaStorage = ReactiveStore.getStore("@luna/storage"); - -const readAllFromStore = async (store: ReactiveStore): Promise> => -{ - const keys = await store.keys(); - const data: Record = {}; - for (const key of keys) - data[key] = await store.get(key); - - return data; -}; - -const exportSettings = async (): Promise => -{ - //plugin settings - const pluginSettings = await readAllFromStore(pluginSettingsStore); - - //installed plugins - const allPlugins = await readAllFromStore(LunaPlugin.pluginStorage); - const installedPlugins: Record = {}; - for (const [key, value] of Object.entries(allPlugins)) - if (value && typeof value === "object" && (value as any).installed === true) - installedPlugins[key] = value; - - //themes - let themes: unknown = null; - try - { - themes = await lunaStorage.get("themes"); - } - catch (err) - { - console.warn("[SettingsTransfer] Could not read themes from @luna/storage: ", err); - } - - //feature flags - const tidalFlags = Tidal.featureFlags; - const featureFlags: Record = {}; - for (const [name, flag] of Object.entries(tidalFlags)) - featureFlags[name] = flag.value; - - return { - version: 1, - timestamp: new Date().toISOString(), - pluginSettings, - installedPlugins, - themes, - featureFlags, - }; -}; - -const downloadJson = (data: ExportData) => -{ - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - - const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - - a.href = url; - a.download = `tidaluna-settings-${dateStr}.json`; - - a.click(); - - URL.revokeObjectURL(url); -}; - -const importSettings = async (data: ExportData) => -{ - //plugin settings - if (data.pluginSettings) - for (const [key, value] of Object.entries(data.pluginSettings)) - await pluginSettingsStore.set(key, value); - - //installed plugins - if (data.installedPlugins) - for (const [key, value] of Object.entries(data.installedPlugins)) - await LunaPlugin.pluginStorage.set(key, value); - - //themes - if (data.themes != null) - { - try - { - await lunaStorage.set("themes", data.themes); - } - catch (err) - { - console.error("[SettingsTransfer] Failed to import themes: ", err); - } - } - - //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 }); - } -}; - -const validateImport = (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.pluginSettings !== "object" && typeof obj.installedPlugins !== "object" && obj.featureFlags === undefined) - return false; - - return true; -}; +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 { - const data = await exportSettings(); + //feature flags + const tidalFlags = Tidal.featureFlags; + const featureFlags: Record = {}; + for (const [name, flag] of Object.entries(tidalFlags)) + if (flag.value) + featureFlags[name] = flag.value; - downloadJson(data); + const data = await SettingsTransfer.dump(stripCode, Object.keys(featureFlags).length > 0 ? featureFlags : null); - const pluginCount = Object.keys(data.pluginSettings).length; - const installedCount = Object.keys(data.installedPlugins).length; - const themeCount = data.themes && typeof data.themes === "object" ? Object.keys(data.themes).length : 0; - const hasFlags = data.featureFlags != null; - Messager.Info(`Exported ${installedCount} installed plugins, ${pluginCount} plugin settings, ${themeCount} themes${hasFlags ? " and feature flags" : ""}`); + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + downloadObject(JSON.stringify(data), `tidaluna-settings-${dateStr}.json`, "application/json"); } catch (err: any) { @@ -171,7 +43,7 @@ export const LunaSettingsTransfer = React.memo(() => { setBusy(false); } - }, []); + }, [stripCode]); const onImportClick = React.useCallback(() => { @@ -189,9 +61,9 @@ export const LunaSettingsTransfer = React.memo(() => try { const text = await file.text(); - const data = JSON.parse(text); + const data: ExportData = JSON.parse(text); - if (!validateImport(data)) + if (!SettingsTransfer.validate(data)) { Messager.Error("Invalid settings file format"); return; @@ -199,7 +71,7 @@ export const LunaSettingsTransfer = React.memo(() => const result = await confirm({ title: "Import Settings", - description: `Import settings exported on ${new Date(data.timestamp).toLocaleString()}? Existing settings will be overwritten and the app will restart.`, + 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) @@ -207,7 +79,17 @@ export const LunaSettingsTransfer = React.memo(() => setBusy(true); - await importSettings(data); + //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..."); @@ -225,15 +107,18 @@ export const LunaSettingsTransfer = React.memo(() => }, []); return ( - + } children="Export Settings" /> } children="Import Settings" /> - - Exports installed plugins, plugin settings and feature flags. Import restores them and restarts the app. - + setStripCode(!stripCode)} + /> ); }); \ No newline at end of file diff --git a/render/src/LunaPlugin.ts b/render/src/LunaPlugin.ts index 918af4c..ef14965 100644 --- a/render/src/LunaPlugin.ts +++ b/render/src/LunaPlugin.ts @@ -168,6 +168,23 @@ 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"; From 6a1c82b12861a93ceaa05b994052ed1e0ff68590 Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:56:24 +0100 Subject: [PATCH 4/6] Settings export/import - export all feature flags --- .../ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx index b7b7f16..10ab441 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -27,8 +27,7 @@ export const LunaSettingsTransfer = React.memo(() => const tidalFlags = Tidal.featureFlags; const featureFlags: Record = {}; for (const [name, flag] of Object.entries(tidalFlags)) - if (flag.value) - featureFlags[name] = flag.value; + featureFlags[name] = flag.value; const data = await SettingsTransfer.dump(stripCode, Object.keys(featureFlags).length > 0 ? featureFlags : null); From 7edc769b4457e10bfe3f79eeb70bcc6ebd69d0d3 Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:43:55 +0100 Subject: [PATCH 5/6] Settings export/import - use userOverrides for feature flags --- .../ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx index 10ab441..ab3cdbe 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -24,10 +24,7 @@ export const LunaSettingsTransfer = React.memo(() => try { //feature flags - const tidalFlags = Tidal.featureFlags; - const featureFlags: Record = {}; - for (const [name, flag] of Object.entries(tidalFlags)) - featureFlags[name] = flag.value; + const featureFlags = redux.store.getState().featureFlags.userOverrides as Record;; const data = await SettingsTransfer.dump(stripCode, Object.keys(featureFlags).length > 0 ? featureFlags : null); From 4815134b74f0455b55e555988b2fff5cf084a10c Mon Sep 17 00:00:00 2001 From: Sinner <64489962+SinnerK0N@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:46:04 +0100 Subject: [PATCH 6/6] Settings export/import - wrap multiline statements in {} --- .../ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx | 2 ++ render/src/LunaPlugin.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx index ab3cdbe..63301ee 100644 --- a/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx +++ b/plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx @@ -83,8 +83,10 @@ export const LunaSettingsTransfer = React.memo(() => { 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..."); diff --git a/render/src/LunaPlugin.ts b/render/src/LunaPlugin.ts index ef14965..966152f 100644 --- a/render/src/LunaPlugin.ts +++ b/render/src/LunaPlugin.ts @@ -180,8 +180,10 @@ export class LunaPlugin { const stripped = structuredClone(data); for (const value of Object.values(stripped)) + { if (value && typeof value === "object") delete (value as any).package?.code; + } return stripped; }