diff --git a/README.md b/README.md index 19a752d..f20b750 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ inputs.tidaLuna.url = "github:Inrixia/TidaLuna" There are now two ways to install the injected tidal-hifi client -#### overlay +#### Overlay Add TidaLuna into your overlay list ```nix nixpkgs.overlay = [ @@ -64,7 +64,7 @@ nixpkgs.overlay = [ after that install the tidal-hifi package as you used to -#### package +#### Package Replace your current `tidal-hifi` package with the new input ```diff @@ -74,6 +74,73 @@ environment.systemPackages = with pkgs; [ ]; ``` +#### Home Manager + +Add the home manager module to `sharedModules` + +```nix +home-manager.sharedModules = [ + inputs.tidaluna.homeManagerModules.default +] +``` + +Then Enable TidaLuna using + +```nix +programs.tidaluna = { + enable = true; +}; +``` + +##### Configuring Stores + +In contrast to the other installation methods, the home manager module comes with no stores preconfigured. You must +include the stores for any plugins you declare in `plugins`. To define stores add them to the `stores` array: + +```nix +programs.tidaluna = { + enable = true; + stores = [ + "https://github.com/Inrixia/luna-plugins/releases/download/dev/store.json" + ]; +}; +``` + +The list of stores which come default with TidaLuna can be found in `plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx`. + +##### Installing and Configuring Plugins + +After having added the stores needed plugins can be defined in the `plugins` array: +```nix +programs.tidaluna = { + enable = true; + stores = [ + "https://github.com/Inrixia/luna-plugins/releases/download/dev/store.json" + ]; + plugins = [ + { + shortURL = "DiscordRPC"; + settingsName = "DiscordRPC"; + settings = { + "displayOnPause" = false; + "displayArtistIcon" = true; + "displayPlaylistButton" = true; + "customStatusText" = "{track} by {artist}"; + }; + } + ]; +}; +``` + +A plugin definition consists of the following three keys: + +- `shortURL`: The name as shown in the "Plugin Store" tab, used for installing the plugin. +- `settingsName`: The key the plugin uses for storing its settings. This may or may not **differ** from the `shortURL`. +- `settings`: Settings to be applied to the plugin. + +To get the `settingsName` and `settings` of all installed plugins run the following command in the developer console +of Tidal: `const idb = await luna.core.ReactiveStore.getStore("@luna/pluginStorage").dump(); console.log(JSON.stringify(idb, null, 2));` + ## Developers Proper developer documentation etc is planned after the inital beta release of Luna. diff --git a/flake.nix b/flake.nix index 29b7ffe..2877419 100644 --- a/flake.nix +++ b/flake.nix @@ -47,6 +47,9 @@ default = pkgs.callPackage ./nix/shell.nix {}; }); + # Home Manager module + homeManagerModules.default = import ./nix/home-manager.nix {inherit self;}; + # Overlay (if preferred) overlays.default = final: prev: {tidal-hifi = final.callPackage ./nix/linux-package.nix {tidal-hifi = prev.tidal-hifi;};}; }; diff --git a/native/injector.ts b/native/injector.ts index 37cc5ce..336dbad 100644 --- a/native/injector.ts +++ b/native/injector.ts @@ -410,3 +410,13 @@ ipcHandle("__Luna.preloadErr", async (_, err: Error) => { console.error(err); electron.dialog.showErrorBox("TidaLuna", err.message); }); + +// Seed settings from a declarative config (e.g. Nix home-manager) +ipcHandle("__Luna.getSeedSettings", async () => { + const seedPath = path.join(bundleDir, "luna-settings.json"); + try { + return JSON.parse(await readFile(seedPath, "utf8")); + } catch { + return null; + } +}); diff --git a/nix/home-manager.nix b/nix/home-manager.nix new file mode 100644 index 0000000..ec6f832 --- /dev/null +++ b/nix/home-manager.nix @@ -0,0 +1,133 @@ +# Called from flake.nix with { inherit self; }, returns a home-manager module. +{ self }: +{ + config, + lib, + ... +}: +let + cfg = config.programs.tidaluna; + pkgs = config._module.args.pkgs; + isDarwin = pkgs.stdenv.isDarwin; + + # Normalize store URLs: strip trailing /store.json since TidaLuna adds it back + normalizeStoreUrl = + url: + let + suffix = "/store.json"; + len = builtins.stringLength url; + suffixLen = builtins.stringLength suffix; + in + if suffixLen <= len && builtins.substring (len - suffixLen) suffixLen url == suffix then + builtins.substring 0 (len - suffixLen) url + else + url; + + # Build the seed settings JSON + pluginNames = if cfg.plugins == null then [ ] else map (p: p.shortURL) cfg.plugins; + + pluginSettings = + if cfg.plugins == null then + { } + else + builtins.listToAttrs ( + map (p: { + name = p.settingsName; + value = p.settings; + }) (lib.filter (p: p.settings != { }) cfg.plugins) + ); + + seedSettings = { + stores = map normalizeStoreUrl cfg.stores; + pluginSettings = pluginSettings; + } + // lib.optionalAttrs (cfg.plugins != null) { + plugins = pluginNames; + }; + + seedSettingsFile = pkgs.writeText "luna-settings.json" (builtins.toJSON seedSettings); + + hasSeedSettings = cfg.stores != [ ] || cfg.plugins != null || pluginSettings != { }; + + # Patch the package to include luna-settings.json in the bundle directory + patchedPackage = + if !hasSeedSettings then + cfg.package + else if isDarwin then + pkgs.runCommand "tidaluna-darwin-patched" { } '' + cp -r ${cfg.package} $out + chmod -R u+w $out + cp ${seedSettingsFile} $out/Applications/TIDAL.app/Contents/Resources/app/luna-settings.json + '' + else + pkgs.runCommand "tidaluna-linux-patched" { } '' + cp -r ${cfg.package} $out + chmod -R u+w $out + cp ${seedSettingsFile} $out/share/tidal-hifi/resources/app/luna-settings.json + ''; +in +{ + options.programs.tidaluna = { + enable = lib.mkEnableOption "TidaLuna, a client mod for the TIDAL music client"; + + package = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "inputs.tidaluna.packages.\${system}.default"; + description = "The TidaLuna package to install (before seed settings are patched in)."; + }; + + stores = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression '' + [ + "https://github.com/Inrixia/luna-plugins/releases/download/dev/store.json" + "https://github.com/meowarex/TidalLuna-Plugins/releases/download/latest/store.json" + ] + ''; + description = '' + Plugin store URLs to register in TidaLuna. This list fully replaces the persisted store list on every startup. + You must include the stores for any plugins you declare in `plugins`. + ''; + }; + + plugins = lib.mkOption { + type = lib.types.nullOr ( + lib.types.listOf ( + lib.types.submodule { + options = { + shortURL = lib.mkOption { + type = lib.types.str; + description = "Plugin identifier used by the resolver (The name shown in the plugin store)."; + example = "meowarex/radiant-lyrics"; + }; + + settingsName = lib.mkOption { + type = lib.types.str; + description = "Key used for plugin settings storage."; + example = "RadiantLyrics"; + }; + + settings = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = "Plugin settings."; + }; + }; + } + ) + ); + default = [ ]; + }; + }; + + config = lib.mkIf cfg.enable { + # Provide the default package from the flake outputs. + programs.tidaluna.package = lib.mkOptionDefault ( + self.packages.${pkgs.stdenv.hostPlatform.system}.default + ); + + # Add the patched package to home.packages on all platforms. + home.packages = [ patchedPackage ]; + }; +} diff --git a/render/src/helpers/applySeedSettingsJSOn.ts b/render/src/helpers/applySeedSettingsJSOn.ts new file mode 100644 index 0000000..2f296ec --- /dev/null +++ b/render/src/helpers/applySeedSettingsJSOn.ts @@ -0,0 +1,106 @@ +import { LunaPlugin } from "../LunaPlugin"; +import { ReactiveStore } from "../ReactiveStore"; + +/** + * Apply settings from a luna-settings.json file. + */ +export async function applySeedSettings(): Promise { + try { + const seed = await __ipcRenderer.invoke("__Luna.getSeedSettings"); + if (seed == null) return; + console.log("[Luna.seed] Applying seed settings"); + + // Full replace of store URLs + if (Array.isArray(seed.stores)) { + const pluginStores = ReactiveStore.getStore("@luna/pluginStores"); + await pluginStores.set("storeUrls", seed.stores); + } + + if (seed.pluginSettings && typeof seed.pluginSettings === "object") { + const pluginStorage = ReactiveStore.getStore("@luna/pluginStorage"); + await pluginStorage.clear(); + console.log("[Luna.seed] Applying plugin settings"); + + for (const [pluginName, settings] of Object.entries(seed.pluginSettings)) { + if (typeof settings !== "object" || settings == null) continue; + await pluginStorage.set(pluginName, settings); + } + + } + + // Install the plugins in the background so it doesn't block the boot sequence + if (Array.isArray(seed.plugins)) { + applyPlugins(seed.plugins).catch((err) => console.error("[Luna.seed] Plugin sync failed:", err)); + } + } catch (err) { + console.error("[Luna.seed] Failed to apply seed settings:", err); + } +} + +/** + * Resolve plugin names against registered stores and reconcile installed plugins. + */ +async function applyPlugins(pluginNames: unknown[]): Promise { + const declaredNames = new Set(pluginNames.filter((n): n is string => typeof n === "string")); + + // Build a name → URL index by fetching every store manifest and then + // each plugin's package.json (which contains the real plugin name). + const pluginIndex = new Map(); + const pluginStores = ReactiveStore.getStore("@luna/pluginStores"); + const storeUrls = await pluginStores.getReactive("storeUrls", []); + await Promise.all( + [...storeUrls].map(async (storeUrl) => { + try { + const res = await fetch(`${storeUrl}/store.json`); + if (!res.ok) return; + const manifest = await res.json(); + if (!Array.isArray(manifest.plugins)) return; + await Promise.all( + manifest.plugins.map(async (pluginFile: string) => { + if (typeof pluginFile !== "string") return; + const baseName = pluginFile.replace(/\.mjs$/, ""); + const pluginUrl = `${storeUrl}/${baseName}`; + try { + const pkg = await LunaPlugin.fetchPackage(pluginUrl); + if (pkg?.name) pluginIndex.set(pkg.name, pluginUrl); + } catch { + // TODO: Show toast in Tidal? + } + }), + ); + } catch { + // TODO: Show toast in Tidal? + } + }), + ); + + // Install / enable every declared plugin + for (const name of declaredNames) { + const url = pluginIndex.get(name); + if (url === undefined) { + console.warn(`[Luna.seed] Plugin "${name}" not found in any registered store`); + continue; + } + try { + const plugin = await LunaPlugin.fromStorage({ url }); + if (!plugin.installed) await plugin.install(); + else if (!plugin.enabled) await plugin.enable(); + } catch (err) { + console.error(`[Luna.seed] Failed to install plugin "${name}":`, err); + } + } + + // Uninstall any persisted plugin not in the declared set (skip core plugins) + const storedNames = await LunaPlugin.pluginStorage.keys(); + for (const name of storedNames) { + if (declaredNames.has(name) || LunaPlugin.corePlugins.has(name)) continue; + try { + const plugin = await LunaPlugin.fromName(name); + if (plugin?.installed) await plugin.uninstall(); + else await LunaPlugin.pluginStorage.del(name); + } catch (err) { + console.error(`[Luna.seed] Failed to uninstall undeclared plugin "${name}":`, err); + } + } + console.log("[Luna.seed] Plugin sync complete"); +} diff --git a/render/src/index.ts b/render/src/index.ts index eb32dca..d6121b7 100644 --- a/render/src/index.ts +++ b/render/src/index.ts @@ -18,6 +18,7 @@ export * from "./SettingsTransfer"; import "./window.core"; import { LunaPlugin } from "./LunaPlugin"; +import { applySeedSettings } from "./helpers/applySeedSettingsJSOn"; // Wrap loading of plugins in a timeout so native/preload.ts can populate modules with @luna/core (see native/preload.ts) setTimeout(async () => { @@ -33,6 +34,9 @@ setTimeout(async () => { // Load ui after lib as it depends on it. await LunaPlugin.fromStorage({ enabled: true, url: "https://luna/luna.ui" }); + // Apply declarative seed settings (e.g. from Nix) before loading user plugins + await applySeedSettings(); + // Load all plugins from storage await LunaPlugin.loadStoredPlugins();