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
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;};};
};
Expand Down
10 changes: 10 additions & 0 deletions native/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
133 changes: 133 additions & 0 deletions nix/home-manager.nix
Original file line number Diff line number Diff line change
@@ -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 ];
};
}
106 changes: 106 additions & 0 deletions render/src/helpers/applySeedSettingsJSOn.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
const declaredNames = new Set<string>(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<string, string>();
const pluginStores = ReactiveStore.getStore("@luna/pluginStores");
const storeUrls = await pluginStores.getReactive<string[]>("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");
}
4 changes: 4 additions & 0 deletions render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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();

Expand Down