Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plugins/lib/src/helpers/downloadObject.ts
Original file line number Diff line number Diff line change
@@ -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);
};
1 change: 1 addition & 0 deletions plugins/lib/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./getPlaybackInfo.dasha.native";
export * from "./observable";
export * from "./parseDate";
export * from "./safeTimeout";
export * from "./downloadObject";
122 changes: 122 additions & 0 deletions plugins/ui/src/SettingsPage/SettingsTab/LunaSettingsTransfer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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<string, boolean>;;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo double semicolon


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<HTMLInputElement>) =>
{
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.`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says "Existing settings will be cleared and replaced" but seems not to?

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 (
<LunaSettings title="Settings Transfer" desc="Exports installed plugins, plugin settings, themes, store URLs and feature flag overrides. Import clears existing settings, restores them and restarts the app.">
<Stack direction="row" spacing={2}>
<LunaButton disabled={busy} onClick={onExport} startIcon={<FileDownloadIcon />} children="Export Settings" />
<LunaButton disabled={busy} onClick={onImportClick} startIcon={<FileUploadIcon />} children="Import Settings" />
<input ref={fileInputRef} type="file" accept=".json" style={{ display: "none" }} onChange={onFileSelected} />
</Stack>
<LunaSwitchSetting
title="Include plugin source code"
desc="Including plugin source code will increase the size of the exported file. This is only useful for exporting dev or unreleased plugins."
checked={!stripCode}
onClick={() => setStripCode(!stripCode)}
/>
</LunaSettings>
);
});
2 changes: 2 additions & 0 deletions plugins/ui/src/SettingsPage/SettingsTab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -17,6 +18,7 @@ export const SettingsTab = React.memo(() => {
return (
<Stack spacing={4}>
<LunaVersionInfo />
<LunaSettingsTransfer />
<LunaFeatureFlags />
<LunaSettings title="Luna core plugins" desc="Plugins providing core luna functionality">
{corePlugins}
Expand Down
19 changes: 19 additions & 0 deletions render/src/LunaPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>
{
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
Expand Down
17 changes: 17 additions & 0 deletions render/src/ReactiveStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,21 @@ export class ReactiveStore {
public keys(): Promise<string[]> {
return idbKeys(this.idbStore);
}

public async dump(): Promise<Record<string, unknown>>
{
const allKeys = await this.keys();
const data: Record<string, unknown> = {};
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);
}
}
70 changes: 70 additions & 0 deletions render/src/SettingsTransfer.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>;
featureFlags: Record<string, boolean> | 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<string, boolean> | null = null): Promise<ExportData>
{
const stores: Record<string, Record<string, unknown>> = {};
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<string, unknown>;
if (obj.version !== 1)
return false;

if (typeof obj.stores !== "object" || obj.stores === null)
return false;

return true;
}
}
1 change: 1 addition & 0 deletions render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down