Bundle a managed, loopback-only Navidrome sidecar with verified release preparation, native source setup, lifecycle controls, licensing, and a polished charcoal/onyx visual system

Hop-State: A_06FNDXP543RHZ4436GTD01G
Hop-Proposal: R_06FNDXNBVJNF9Q2KMKT0ASR
Hop-Task: T_06FNDRZ9CM02JWJ98SRNFNR
Hop-Attempt: AT_06FNDRZ9CNGSWJSFVV54DR8
This commit is contained in:
2026-07-12 08:34:00 -07:00
committed by Hop
parent 38b140905f
commit ed99fc7f09
22 changed files with 1661 additions and 114 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules/
dist/
src-tauri/target/
src-tauri/binaries/navidrome-*
src-tauri/resources/navidrome-LICENSE
.DS_Store
*.log
*.tsbuildinfo
+27 -6
View File
@@ -2,7 +2,7 @@
Your library, wherever you listen.
Resonant is a desktop-first music player for personal, self-hosted collections. It connects directly to Navidrome and other OpenSubsonic-compatible servers while keeping the listening experience calm, immediate, and separate from server administration.
Resonant is a desktop-first music player for personal collections. The desktop app can privately serve a folder on this computer through its bundled Navidrome engine, or connect directly to an existing Navidrome or OpenSubsonic-compatible server.
## What works in v0.1
@@ -10,6 +10,9 @@ Resonant is a desktop-first music player for personal, self-hosted collections.
- Functional playback controls with seek, volume, previous, and next actions
- An offline demo library with locally synthesized audio, so the app is useful immediately after cloning
- OpenSubsonic token authentication, connection testing, album loading, random-track loading, artwork, and streaming URLs
- A managed Navidrome 0.61.2 sidecar bound to `127.0.0.1`, with native folder selection, first-user setup, process lifecycle control, and app-private server data
- Official release download and SHA-256 verification for Apple Silicon/Intel macOS, x64/ARM64 Linux, and x64 Windows desktop builds
- A charcoal and onyx interface with coral reserved for active state and primary action
- A Tauri 2 desktop shell for macOS, Windows, and Linux development
- Keyboard-visible controls, reduced-motion support, semantic labels, and a WCAG 2.2 AA target
@@ -24,21 +27,28 @@ npm install
npm run dev
```
To run the desktop shell:
To run the desktop shell with the bundled server:
```sh
npm run tauri dev
npm run desktop:dev
```
The first desktop run downloads the pinned official Navidrome binary for the current Rust target, verifies its release digest, and places it in Tauri's ignored sidecar build directory. The binary is bundled into packaged apps, not committed to this repository.
To validate the full project:
```sh
npm run check
```
## Connect a server
## Choose a library source
Open the server control in the lower-left corner, then enter the URL and credentials for a Navidrome or OpenSubsonic server. Browser development requires the server to allow the development origin. The Tauri networking layer will replace browser fetch before packaged distribution so remote libraries do not depend on browser CORS policy.
Open the library control in the lower-left corner.
- **This computer:** choose a music folder and a local password. Resonant starts its bundled Navidrome process on `127.0.0.1:4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes.
- **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run.
The local password and remote server password are held only in memory. Browser development can preview the interface and connect to servers that allow the development origin, but native folder selection and the managed server require the Tauri desktop app.
## Architecture
@@ -51,6 +61,10 @@ Resonant desktop client
│ ├── library and artwork requests
│ └── authenticated stream URLs
└── Tauri shell
└── managed Navidrome sidecar
├── official, pinned, checksum-verified binary
├── loopback-only HTTP server
└── app-private database and logs
Navidrome or another OpenSubsonic server
├── scans and indexes music
@@ -60,12 +74,19 @@ Navidrome or another OpenSubsonic server
The protocol boundary is deliberate. Resonant owns the product experience without forking the media engine, and the server can be replaced as long as it speaks OpenSubsonic.
## Current boundaries
- Navidrome is bundled; FFmpeg is not yet bundled. Native browser-supported audio formats play directly, while formats that require transcoding still need FFmpeg available on the system.
- The local server is intentionally reachable only from this computer. Listening from another device will be a separate, explicitly enabled feature.
- Credentials are session-only until OS keychain integration lands.
## Next milestones
1. Move remote requests into Tauri's HTTP plugin and store credentials in the OS keychain.
2. Add album and artist detail routes with deterministic library pagination.
3. Persist the queue, favorites, and playback position, then synchronize favorites back to the server.
4. Add native media keys, system now-playing metadata, ReplayGain, and gapless transition support.
5. Bundle an optional managed Navidrome sidecar for the “choose a folder” setup path.
5. Add an explicit remote-access flow without weakening the local-only default.
Product direction and accessibility principles live in [`PRODUCT.md`](./PRODUCT.md).
Third-party licensing and source links live in [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
+12
View File
@@ -0,0 +1,12 @@
# Third-party notices
## Navidrome
Resonant desktop builds bundle an unmodified Navidrome 0.61.2 executable as the local music server.
- Project: https://github.com/navidrome/navidrome
- Release source: https://github.com/navidrome/navidrome/tree/v0.61.2
- License: GNU General Public License v3.0
- License text: https://github.com/navidrome/navidrome/blob/v0.61.2/LICENSE
Navidrome is downloaded from its official GitHub release during the desktop build and verified against the SHA-256 digest published with that release. Navidrome is a separate process and is not modified by Resonant.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171413" />
<meta name="theme-color" content="#101116" />
<meta name="description" content="Resonant, your library wherever you listen." />
<title>Resonant</title>
</head>
+10
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -1306,6 +1307,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+5 -1
View File
@@ -8,11 +8,15 @@
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"sidecar:prepare": "node scripts/prepare-navidrome.mjs",
"desktop:dev": "npm run sidecar:prepare && tauri dev",
"desktop:build": "npm run sidecar:prepare && tauri build",
"tauri": "tauri",
"check": "npm run test && npm run build && cargo check --manifest-path src-tauri/Cargo.toml"
"check": "npm run sidecar:prepare && npm run test && npm run build && cargo check --manifest-path src-tauri/Cargo.toml"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+85
View File
@@ -0,0 +1,85 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const VERSION = "0.61.2";
const RELEASE_API = `https://api.github.com/repos/navidrome/navidrome/releases/tags/v${VERSION}`;
const targetTriple = execFileSync("rustc", ["--print", "host-tuple"], { encoding: "utf8" }).trim();
const archives = {
"aarch64-apple-darwin": `navidrome_${VERSION}_darwin_arm64.tar.gz`,
"x86_64-apple-darwin": `navidrome_${VERSION}_darwin_amd64.tar.gz`,
"aarch64-unknown-linux-gnu": `navidrome_${VERSION}_linux_arm64.tar.gz`,
"x86_64-unknown-linux-gnu": `navidrome_${VERSION}_linux_amd64.tar.gz`,
"x86_64-pc-windows-msvc": `navidrome_${VERSION}_windows_amd64.zip`,
};
const archiveName = archives[targetTriple];
if (!archiveName) {
throw new Error(`Navidrome ${VERSION} is not configured for Rust target ${targetTriple}.`);
}
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outputDirectory = resolve("src-tauri/binaries");
const outputPath = join(outputDirectory, `navidrome-${targetTriple}${extension}`);
const resourceDirectory = resolve("src-tauri/resources");
const licensePath = join(resourceDirectory, "navidrome-LICENSE");
await mkdir(outputDirectory, { recursive: true });
await mkdir(resourceDirectory, { recursive: true });
let binaryReady = false;
let licenseReady = false;
try {
await readFile(outputPath);
binaryReady = true;
} catch {
// Download the pinned official release below when missing.
}
try {
await readFile(licensePath);
licenseReady = true;
} catch {
// Include Navidrome's GPL license in the packaged app when missing.
}
const headers = { Accept: "application/vnd.github+json", "User-Agent": "Resonant-sidecar-preparer" };
if (!licenseReady) {
const licenseResponse = await fetch(`https://raw.githubusercontent.com/navidrome/navidrome/v${VERSION}/LICENSE`, { headers });
if (!licenseResponse.ok) throw new Error(`Could not download the Navidrome license (${licenseResponse.status}).`);
await writeFile(licensePath, await licenseResponse.text());
}
if (binaryReady) {
process.stdout.write(`Navidrome ${VERSION} sidecar is ready for ${targetTriple}.\n`);
process.exit(0);
}
const releaseResponse = await fetch(RELEASE_API, { headers });
if (!releaseResponse.ok) throw new Error(`Could not read the Navidrome release metadata (${releaseResponse.status}).`);
const release = await releaseResponse.json();
const asset = release.assets.find((candidate) => candidate.name === archiveName);
if (!asset) throw new Error(`The official Navidrome release does not contain ${archiveName}.`);
const archiveResponse = await fetch(asset.browser_download_url, { headers });
if (!archiveResponse.ok) throw new Error(`Could not download ${archiveName} (${archiveResponse.status}).`);
const archive = Buffer.from(await archiveResponse.arrayBuffer());
const actualDigest = createHash("sha256").update(archive).digest("hex");
const expectedDigest = asset.digest?.replace(/^sha256:/, "");
if (!expectedDigest || actualDigest !== expectedDigest) {
throw new Error(`Checksum verification failed for ${archiveName}.`);
}
const workingDirectory = await mkdtemp(join(tmpdir(), "resonant-navidrome-"));
try {
const archivePath = join(workingDirectory, archiveName);
await writeFile(archivePath, archive);
execFileSync("tar", ["-xf", archivePath], { cwd: workingDirectory, stdio: "inherit" });
const extractedBinary = join(workingDirectory, `navidrome${extension}`);
await copyFile(extractedBinary, outputPath);
if (!extension) await chmod(outputPath, 0o755);
} finally {
await rm(workingDirectory, { recursive: true, force: true });
}
process.stdout.write(`Downloaded and verified Navidrome ${VERSION} for ${targetTriple}.\n`);
+282 -1
View File
@@ -701,6 +701,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -718,6 +727,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1472,6 +1491,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1932,6 +1970,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2010,12 +2049,33 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2404,6 +2464,32 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
@@ -2691,12 +2777,54 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3050,6 +3178,85 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
dependencies = [
"encoding_rs",
"log",
"open",
"os_pipe",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -4040,6 +4247,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4073,13 +4289,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4110,6 +4343,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4122,6 +4361,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4134,12 +4379,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4152,6 +4409,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4164,6 +4427,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4176,6 +4445,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4188,6 +4463,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
+2
View File
@@ -16,3 +16,5 @@ tauri-build = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default",
"description": "Default desktop permissions for Resonant",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": ["core:default", "dialog:allow-open"]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default desktop permissions for Resonant","local":true,"windows":["main"],"permissions":["core:default"]}}
{"default":{"identifier":"default","description":"Default desktop permissions for Resonant","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
+386
View File
@@ -134,6 +134,216 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
"const": "shell:default",
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
},
{
"description": "Enables the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-execute",
"markdownDescription": "Enables the execute command without any pre-configured scope."
},
{
"description": "Enables the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-kill",
"markdownDescription": "Enables the kill command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-spawn",
"markdownDescription": "Enables the spawn command without any pre-configured scope."
},
{
"description": "Enables the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-stdin-write",
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
},
{
"description": "Denies the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-execute",
"markdownDescription": "Denies the execute command without any pre-configured scope."
},
{
"description": "Denies the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-kill",
"markdownDescription": "Denies the kill command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-spawn",
"markdownDescription": "Denies the spawn command without any pre-configured scope."
},
{
"description": "Denies the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "ShellScopeEntry",
"description": "Shell scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"cmd",
"name"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"name",
"sidecar"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
},
"deny": {
"items": {
"title": "ShellScopeEntry",
"description": "Shell scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"cmd",
"name"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"name",
"sidecar"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2401,138 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
"const": "shell:default",
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
},
{
"description": "Enables the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-execute",
"markdownDescription": "Enables the execute command without any pre-configured scope."
},
{
"description": "Enables the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-kill",
"markdownDescription": "Enables the kill command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-spawn",
"markdownDescription": "Enables the spawn command without any pre-configured scope."
},
{
"description": "Enables the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-stdin-write",
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
},
{
"description": "Denies the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-execute",
"markdownDescription": "Denies the execute command without any pre-configured scope."
},
{
"description": "Denies the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-kill",
"markdownDescription": "Denies the kill command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-spawn",
"markdownDescription": "Denies the spawn command without any pre-configured scope."
},
{
"description": "Denies the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
},
@@ -2287,6 +2629,50 @@
]
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
{
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
"type": "boolean"
},
{
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
"type": "array",
"items": {
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
}
}
]
}
}
}
+386
View File
@@ -134,6 +134,216 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
"const": "shell:default",
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
},
{
"description": "Enables the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-execute",
"markdownDescription": "Enables the execute command without any pre-configured scope."
},
{
"description": "Enables the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-kill",
"markdownDescription": "Enables the kill command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-spawn",
"markdownDescription": "Enables the spawn command without any pre-configured scope."
},
{
"description": "Enables the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-stdin-write",
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
},
{
"description": "Denies the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-execute",
"markdownDescription": "Denies the execute command without any pre-configured scope."
},
{
"description": "Denies the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-kill",
"markdownDescription": "Denies the kill command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-spawn",
"markdownDescription": "Denies the spawn command without any pre-configured scope."
},
{
"description": "Denies the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "ShellScopeEntry",
"description": "Shell scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"cmd",
"name"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"name",
"sidecar"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
},
"deny": {
"items": {
"title": "ShellScopeEntry",
"description": "Shell scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"cmd",
"name"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"name",
"sidecar"
],
"properties": {
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
{
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2401,138 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
"const": "shell:default",
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
},
{
"description": "Enables the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-execute",
"markdownDescription": "Enables the execute command without any pre-configured scope."
},
{
"description": "Enables the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-kill",
"markdownDescription": "Enables the kill command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-spawn",
"markdownDescription": "Enables the spawn command without any pre-configured scope."
},
{
"description": "Enables the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:allow-stdin-write",
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
},
{
"description": "Denies the execute command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-execute",
"markdownDescription": "Denies the execute command without any pre-configured scope."
},
{
"description": "Denies the kill command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-kill",
"markdownDescription": "Denies the kill command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the spawn command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-spawn",
"markdownDescription": "Denies the spawn command without any pre-configured scope."
},
{
"description": "Denies the stdin_write command without any pre-configured scope.",
"type": "string",
"const": "shell:deny-stdin-write",
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
},
@@ -2287,6 +2629,50 @@
]
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
{
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
"type": "boolean"
},
{
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
"type": "array",
"items": {
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
}
}
]
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+3 -2
View File
@@ -1,6 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="116" fill="#d99265"/>
<g fill="none" stroke="#2a1d19" stroke-linecap="round" stroke-width="30">
<rect width="512" height="512" rx="116" fill="#15161b"/>
<rect x="15" y="15" width="482" height="482" rx="101" fill="none" stroke="#2d2f38" stroke-width="18"/>
<g fill="none" stroke="#ef8f80" stroke-linecap="round" stroke-width="30">
<path d="M130 272v-32"/>
<path d="M193 327V185"/>
<path d="M256 369V143"/>

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 487 B

+169
View File
@@ -1,6 +1,175 @@
use serde::Serialize;
use std::{fs, path::PathBuf, sync::Mutex};
use tauri::{AppHandle, Manager, State, WindowEvent};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
};
const NAVIDROME_VERSION: &str = "0.61.2";
const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533";
struct ManagedServer {
child: CommandChild,
music_folder: String,
pid: u32,
}
#[derive(Default)]
struct ManagedServerState(Mutex<Option<ManagedServer>>);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagedServerStatus {
running: bool,
url: String,
music_folder: Option<String>,
version: String,
}
fn status_for(server: Option<&ManagedServer>) -> ManagedServerStatus {
ManagedServerStatus {
running: server.is_some(),
url: MANAGED_SERVER_URL.to_string(),
music_folder: server.map(|server| server.music_folder.clone()),
version: NAVIDROME_VERSION.to_string(),
}
}
fn stop_server(state: &ManagedServerState) -> Result<(), String> {
let mut server = state
.0
.lock()
.map_err(|_| "The local server state could not be accessed.".to_string())?;
if let Some(server) = server.take() {
server
.child
.kill()
.map_err(|error| format!("The local server could not be stopped: {error}"))?;
}
Ok(())
}
#[tauri::command]
fn managed_server_status(
state: State<'_, ManagedServerState>,
) -> Result<ManagedServerStatus, String> {
let server = state
.0
.lock()
.map_err(|_| "The local server state could not be accessed.".to_string())?;
Ok(status_for(server.as_ref()))
}
#[tauri::command]
fn start_managed_server(
app: AppHandle,
state: State<'_, ManagedServerState>,
music_folder: String,
password: String,
) -> Result<ManagedServerStatus, String> {
if password.chars().count() < 8 {
return Err("Choose a local password with at least 8 characters.".to_string());
}
let music_path = PathBuf::from(music_folder.trim())
.canonicalize()
.map_err(|_| "That music folder could not be found.".to_string())?;
if !music_path.is_dir() {
return Err("Choose a folder that contains your music.".to_string());
}
stop_server(&state)?;
let data_folder = app
.path()
.app_data_dir()
.map_err(|error| format!("The Resonant data folder could not be found: {error}"))?
.join("navidrome");
fs::create_dir_all(&data_folder)
.map_err(|error| format!("The local server data folder could not be created: {error}"))?;
let log_file = data_folder.join("navidrome.log");
let sidecar = app
.shell()
.sidecar("navidrome")
.map_err(|error| format!("The bundled music server is unavailable: {error}"))?
.env("ND_ADDRESS", "127.0.0.1")
.env("ND_PORT", "4533")
.env("ND_MUSICFOLDER", &music_path)
.env("ND_DATAFOLDER", &data_folder)
.env("ND_LOGFILE", &log_file)
.env("ND_LOGLEVEL", "info")
.env("ND_ENABLEINSIGHTSCOLLECTOR", "false")
.env("ND_ENABLEEXTERNALSERVICES", "false")
.env("ND_DEVAUTOCREATEADMINPASSWORD", password);
let (mut events, child) = sidecar
.spawn()
.map_err(|error| format!("The bundled music server could not start: {error}"))?;
let pid = child.pid();
let music_folder = music_path.to_string_lossy().into_owned();
{
let mut server = state
.0
.lock()
.map_err(|_| "The local server state could not be accessed.".to_string())?;
*server = Some(ManagedServer {
child,
music_folder: music_folder.clone(),
pid,
});
}
let event_app = app.clone();
tauri::async_runtime::spawn(async move {
while let Some(event) = events.recv().await {
if matches!(event, CommandEvent::Terminated(_) | CommandEvent::Error(_)) {
let state = event_app.state::<ManagedServerState>();
if let Ok(mut server) = state.0.lock() {
if server.as_ref().is_some_and(|server| server.pid == pid) {
server.take();
}
}
break;
}
}
});
Ok(ManagedServerStatus {
running: true,
url: MANAGED_SERVER_URL.to_string(),
music_folder: Some(music_folder),
version: NAVIDROME_VERSION.to_string(),
})
}
#[tauri::command]
fn stop_managed_server(
state: State<'_, ManagedServerState>,
) -> Result<ManagedServerStatus, String> {
stop_server(&state)?;
Ok(status_for(None))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(ManagedServerState::default())
.invoke_handler(tauri::generate_handler![
managed_server_status,
start_managed_server,
stop_managed_server
])
.on_window_event(|window, event| {
if matches!(event, WindowEvent::Destroyed) {
let state = window.app_handle().state::<ManagedServerState>();
let _ = stop_server(&state);
}
})
.run(tauri::generate_context!())
.expect("error while running Resonant");
}
+4 -2
View File
@@ -26,7 +26,9 @@
}
},
"bundle": {
"active": false,
"targets": "all"
"active": true,
"targets": "all",
"externalBin": ["binaries/navidrome"],
"resources": ["resources/navidrome-LICENSE"]
}
}
+154 -45
View File
@@ -1,4 +1,4 @@
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
Album as AlbumIcon,
AudioLines,
@@ -6,6 +6,8 @@ import {
CircleUserRound,
Disc3,
Ellipsis,
FolderOpen,
HardDrive,
Heart,
Home,
Library,
@@ -13,6 +15,7 @@ import {
Menu,
Pause,
Play,
Power,
Plus,
Radio,
Search,
@@ -27,6 +30,14 @@ import {
} from "lucide-react";
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
import { usePlayer } from "./hooks/usePlayer";
import {
chooseMusicFolder,
getManagedServerStatus,
isDesktopRuntime,
startManagedServer,
stopManagedServer,
type ManagedServerStatus,
} from "./lib/managedServer";
import { buildApiUrl, requestSubsonic, streamUrl } from "./lib/subsonic";
import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types";
@@ -64,6 +75,12 @@ export default function App() {
const [favorites, setFavorites] = useState(() => new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)));
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local");
const [managedStatus, setManagedStatus] = useState<ManagedServerStatus | null>(null);
const [localSetup, setLocalSetup] = useState({
musicFolder: localStorage.getItem("resonant.musicFolder") ?? "",
password: "",
});
const [server, setServer] = useState<ServerConfig>({
url: localStorage.getItem("resonant.serverUrl") ?? "",
username: localStorage.getItem("resonant.username") ?? "",
@@ -71,6 +88,15 @@ export default function App() {
});
const player = usePlayer(libraryTracks);
useEffect(() => {
getManagedServerStatus().then((status) => {
setManagedStatus(status);
if (status.musicFolder) {
setLocalSetup((current) => ({ ...current, musicFolder: status.musicFolder ?? current.musicFolder }));
}
}).catch(() => undefined);
}, []);
const visibleTracks = useMemo(() => {
const needle = query.trim().toLowerCase();
let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks;
@@ -89,53 +115,116 @@ export default function App() {
return next;
});
const loadServerLibrary = async (config: ServerConfig, connectedMessage: string) => {
await requestSubsonic(config, "ping");
const [albumResponse, songResponse] = await Promise.all([
requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "recent", size: 18 }),
requestSubsonic<{ randomSongs?: { song?: ServerSong[] } }>(config, "getRandomSongs", { size: 40 }),
]);
const nextAlbums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
id: album.id,
title: album.name,
artist: album.artist ?? "Unknown artist",
year: album.year ?? 0,
coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
}));
const nextTracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
id: song.id,
title: song.title,
artist: song.artist ?? "Unknown artist",
album: song.album ?? "Unknown album",
albumId: song.albumId ?? "",
duration: song.duration ?? 0,
coverTone: tones[index % tones.length],
coverUrl: song.coverArt ? buildApiUrl(config, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined,
streamUrl: streamUrl(config, song.id),
favorite: Boolean(song.starred),
}));
if (nextAlbums.length) setLibraryAlbums(nextAlbums);
if (nextTracks.length) {
setLibraryTracks(nextTracks);
setFavorites(new Set(nextTracks.filter((track) => track.favorite).map((track) => track.id)));
player.replaceTracks(nextTracks);
}
setServer(config);
setConnectionStatus("connected");
setConnectionMessage(connectedMessage);
setView("home");
};
const connect = async (event: FormEvent) => {
event.preventDefault();
setConnectionStatus("connecting");
setConnectionMessage("Reaching your library…");
try {
await requestSubsonic(server, "ping");
const [albumResponse, songResponse] = await Promise.all([
requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(server, "getAlbumList2", { type: "recent", size: 18 }),
requestSubsonic<{ randomSongs?: { song?: ServerSong[] } }>(server, "getRandomSongs", { size: 40 }),
]);
const nextAlbums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
id: album.id,
title: album.name,
artist: album.artist ?? "Unknown artist",
year: album.year ?? 0,
coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(server, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
}));
const nextTracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
id: song.id,
title: song.title,
artist: song.artist ?? "Unknown artist",
album: song.album ?? "Unknown album",
albumId: song.albumId ?? "",
duration: song.duration ?? 0,
coverTone: tones[index % tones.length],
coverUrl: song.coverArt ? buildApiUrl(server, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined,
streamUrl: streamUrl(server, song.id),
favorite: Boolean(song.starred),
}));
if (nextAlbums.length) setLibraryAlbums(nextAlbums);
if (nextTracks.length) {
setLibraryTracks(nextTracks);
setFavorites(new Set(nextTracks.filter((track) => track.favorite).map((track) => track.id)));
player.replaceTracks(nextTracks);
}
await loadServerLibrary(server, `Connected as ${server.username}`);
localStorage.setItem("resonant.serverUrl", server.url);
localStorage.setItem("resonant.username", server.username);
setConnectionStatus("connected");
setConnectionMessage(`Connected as ${server.username}`);
setView("home");
localStorage.setItem("resonant.sourceMode", "remote");
} catch (error) {
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Could not connect to that server.");
}
};
const pickMusicFolder = async () => {
if (!isDesktopRuntime()) {
setConnectionStatus("error");
setConnectionMessage("Folder selection is available in the Resonant desktop app.");
return;
}
const musicFolder = await chooseMusicFolder();
if (musicFolder) setLocalSetup((current) => ({ ...current, musicFolder }));
};
const startLocalLibrary = async (event: FormEvent) => {
event.preventDefault();
setConnectionStatus("connecting");
setConnectionMessage("Starting your private music server…");
try {
const status = await startManagedServer(localSetup.musicFolder, localSetup.password);
setManagedStatus(status);
const config = { url: status.url, username: "admin", password: localSetup.password };
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
await requestSubsonic(config, "ping");
lastError = undefined;
break;
} catch (error) {
lastError = error;
await new Promise((resolve) => window.setTimeout(resolve, 500));
}
}
if (lastError) throw lastError;
await loadServerLibrary(config, "Your local library is ready");
localStorage.setItem("resonant.musicFolder", localSetup.musicFolder);
localStorage.setItem("resonant.sourceMode", "local");
} catch (error) {
setManagedStatus(await getManagedServerStatus().catch(() => null));
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
}
};
const stopLocalLibrary = async () => {
try {
const status = await stopManagedServer();
setManagedStatus(status);
setLibraryAlbums(demoAlbums);
setLibraryTracks(demoTracks);
player.replaceTracks(demoTracks);
setConnectionStatus("demo");
setConnectionMessage("Listening with the demo library");
} catch (error) {
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not stop.");
}
};
const goTo = (next: View) => {
setView(next);
setMobileNav(false);
@@ -158,7 +247,7 @@ export default function App() {
<button type="button" onClick={() => goTo("home")}><Radio size={18} /><span>Resonant Radio</span><em>Soon</em></button>
</nav>
<button className="server-pill" type="button" onClick={() => goTo("settings")}>
<span className={`status-dot status-dot--${connectionStatus}`} /><span><strong>{connectionStatus === "connected" ? "Your server" : "Demo library"}</strong><small>{connectionMessage}</small></span><Settings size={16} />
<span className={`status-dot status-dot--${connectionStatus}`} /><span><strong>{managedStatus?.running ? "This computer" : connectionStatus === "connected" ? "Your server" : "Demo library"}</strong><small>{connectionMessage}</small></span><Settings size={16} />
</button>
</aside>
@@ -174,15 +263,35 @@ export default function App() {
{view === "settings" ? (
<section className="settings-view" aria-labelledby="settings-title">
<div className="eyebrow"><Server size={15} /> Library source</div>
<h1 id="settings-title">Connect your music</h1>
<p>Use any Navidrome or OpenSubsonic-compatible server. Your password stays in memory for this session.</p>
<form className="connection-form" onSubmit={connect}>
<label>Server address<input type="url" required placeholder="https://music.example.com" value={server.url} onChange={(event) => setServer({ ...server, url: event.target.value })} /></label>
<div className="form-row"><label>Username<input required autoComplete="username" value={server.username} onChange={(event) => setServer({ ...server, username: event.target.value })} /></label><label>Password<input type="password" required autoComplete="current-password" value={server.password} onChange={(event) => setServer({ ...server, password: event.target.value })} /></label></div>
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
</form>
<div className="privacy-note"><Disc3 size={22} /><div><strong>A client, not another server</strong><p>Resonant speaks OpenSubsonic directly. Navidrome remains in charge of scanning, metadata, artwork, and transcoding.</p></div></div>
<h1 id="settings-title">Bring your library home.</h1>
<p>Choose a folder on this computer or connect a server you already run. Resonant keeps the machinery out of your way.</p>
<div className="source-switch" role="group" aria-label="Library source">
<button type="button" aria-pressed={sourceMode === "local"} onClick={() => setSourceMode("local")}><HardDrive size={18} /><span><strong>This computer</strong><small>Managed by Resonant</small></span></button>
<button type="button" aria-pressed={sourceMode === "remote"} onClick={() => setSourceMode("remote")}><Server size={18} /><span><strong>Existing server</strong><small>Navidrome or OpenSubsonic</small></span></button>
</div>
{sourceMode === "local" ? (
<form className="connection-form" onSubmit={startLocalLibrary}>
<div className="managed-heading"><div className="managed-icon"><AudioLines size={20} /></div><div><strong>Your music, served privately</strong><p>Resonant includes Navidrome and listens only on this computer.</p></div><span>v{managedStatus?.version ?? "0.61.2"}</span></div>
<label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label>
<label>Local password<input type="password" required minLength={8} autoComplete="new-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Used only to protect the private library on this computer. It stays in memory for this session.</small></label>
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
<div className="form-actions">
{managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>}
<button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button>
<button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Starting library…" : managedStatus?.running ? "Restart library" : "Start my library"}<ChevronRight size={17} /></button>
</div>
</form>
) : (
<form className="connection-form" onSubmit={connect}>
<label>Server address<input type="url" required placeholder="https://music.example.com" value={server.url} onChange={(event) => setServer({ ...server, url: event.target.value })} /></label>
<div className="form-row"><label>Username<input required autoComplete="username" value={server.username} onChange={(event) => setServer({ ...server, username: event.target.value })} /></label><label>Password<input type="password" required autoComplete="current-password" value={server.password} onChange={(event) => setServer({ ...server, password: event.target.value })} /></label></div>
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
</form>
)}
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Private by default" : "Your server stays yours"}</strong><p>{sourceMode === "local" ? "The managed server binds to 127.0.0.1, keeps its database in Resonants app data, and stops when Resonant closes." : "Resonant speaks OpenSubsonic directly. Your password stays in memory for this session."}</p></div></div>
</section>
) : (
<div className="library-view">
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getManagedServerStatus, isDesktopRuntime } from "./managedServer";
describe("managed server bridge", () => {
it("reports the browser preview as a non-desktop runtime", () => {
expect(isDesktopRuntime()).toBe(false);
});
it("returns a stopped preview status without invoking Tauri", async () => {
await expect(getManagedServerStatus()).resolves.toMatchObject({
running: false,
url: "http://127.0.0.1:4533",
version: "0.61.2",
});
});
});
+40
View File
@@ -0,0 +1,40 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
export interface ManagedServerStatus {
running: boolean;
url: string;
musicFolder?: string;
version: string;
}
export function isDesktopRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export async function chooseMusicFolder(): Promise<string | null> {
if (!isDesktopRuntime()) return null;
const selection = await open({
directory: true,
multiple: false,
title: "Choose your music folder",
});
return typeof selection === "string" ? selection : null;
}
export async function getManagedServerStatus(): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) {
return { running: false, url: "http://127.0.0.1:4533", version: "0.61.2" };
}
return invoke<ManagedServerStatus>("managed_server_status");
}
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
}
export async function stopManagedServer(): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
return invoke<ManagedServerStatus>("stop_managed_server");
}
+74 -53
View File
@@ -2,22 +2,22 @@
:root {
font-family: "DM Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: oklch(0.91 0.012 55);
background: oklch(0.145 0.012 38);
color: oklch(0.92 0.008 275);
background: oklch(0.13 0.008 275);
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: oklch(0.91 0.012 55);
--muted: oklch(0.67 0.018 45);
--faint: oklch(0.49 0.015 45);
--canvas: oklch(0.145 0.012 38);
--surface: oklch(0.18 0.014 38);
--surface-raised: oklch(0.225 0.017 39);
--line: oklch(0.31 0.018 42 / 0.66);
--accent: oklch(0.69 0.135 39);
--accent-bright: oklch(0.76 0.13 45);
--ink: oklch(0.92 0.008 275);
--muted: oklch(0.68 0.012 275);
--faint: oklch(0.5 0.012 275);
--canvas: oklch(0.13 0.008 275);
--surface: oklch(0.17 0.009 275);
--surface-raised: oklch(0.22 0.012 275);
--line: oklch(0.31 0.014 275 / 0.7);
--accent: oklch(0.68 0.17 28);
--accent-bright: oklch(0.75 0.15 28);
--success: oklch(0.72 0.12 145);
--danger: oklch(0.68 0.16 24);
--focus: oklch(0.82 0.115 78);
--focus: oklch(0.78 0.12 255);
--player-height: 94px;
color-scheme: dark;
}
@@ -37,64 +37,64 @@ button:disabled { cursor: wait; opacity: 0.65; }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); }
.sidebar { position: relative; z-index: 30; display: flex; flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.16 0.014 38); }
.sidebar { position: relative; z-index: 30; display: flex; height: calc(100vh - var(--player-height)); flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.15 0.009 275); }
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 9px; color: oklch(0.2 0.05 37); background: var(--accent-bright); box-shadow: 0 8px 30px oklch(0.59 0.13 37 / 0.18); }
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 9px; color: oklch(0.16 0.015 275); background: var(--accent-bright); box-shadow: 0 8px 30px oklch(0.55 0.16 28 / 0.16); }
.sidebar nav { display: grid; gap: 4px; }
.nav-label { margin: 0 10px 8px; color: var(--faint); font-size: 0.67rem; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; }
.nav-label--second { margin-top: 25px; }
.sidebar nav button { display: flex; width: 100%; align-items: center; gap: 11px; padding: 9px 10px; border: 0; border-radius: 9px; color: var(--muted); background: transparent; font-size: 0.86rem; font-weight: 500; text-align: left; transition: color 160ms ease, background 160ms ease; }
.sidebar nav button:hover { color: var(--ink); background: oklch(0.24 0.015 40 / 0.58); }
.sidebar nav button.active { color: var(--ink); background: oklch(0.28 0.029 39 / 0.72); }
.sidebar nav button:hover { color: var(--ink); background: oklch(0.23 0.012 275 / 0.62); }
.sidebar nav button.active { color: var(--ink); background: oklch(0.25 0.018 275 / 0.82); }
.sidebar nav button.active svg { color: var(--accent-bright); }
.sidebar nav em { margin-left: auto; padding: 2px 6px; border: 1px solid var(--line); border-radius: 99px; color: var(--faint); font-size: 0.58rem; font-style: normal; letter-spacing: 0.05em; text-transform: uppercase; }
.server-pill { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; margin-top: auto; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: oklch(0.205 0.016 39); text-align: left; }
.server-pill:hover { border-color: oklch(0.42 0.03 43); background: oklch(0.225 0.018 39); }
.server-pill { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; margin-top: auto; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: oklch(0.19 0.01 275); text-align: left; }
.server-pill:hover { border-color: oklch(0.43 0.02 275); background: oklch(0.22 0.012 275); }
.server-pill strong, .server-pill small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-pill strong { font-size: 0.72rem; font-weight: 600; }
.server-pill small { margin-top: 2px; color: var(--faint); font-size: 0.61rem; }
.server-pill > svg { color: var(--faint); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); box-shadow: 0 0 0 4px oklch(0.55 0.01 40 / 0.12); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); box-shadow: 0 0 0 4px oklch(0.55 0.01 275 / 0.12); }
.status-dot--connected { background: var(--success); box-shadow: 0 0 0 4px oklch(0.72 0.12 145 / 0.12); }
.status-dot--connecting { background: var(--focus); animation: pulse 1.3s ease-in-out infinite; }
.status-dot--error { background: var(--danger); box-shadow: 0 0 0 4px oklch(0.68 0.16 24 / 0.12); }
main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: auto; scrollbar-color: oklch(0.34 0.018 42) transparent; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: 18px; height: 68px; padding: 0 34px; background: oklch(0.145 0.012 38 / 0.94); border-bottom: 1px solid oklch(0.28 0.015 40 / 0.5); }
.search-field { display: flex; width: min(380px, 48vw); align-items: center; gap: 9px; padding: 8px 11px; border: 1px solid transparent; border-radius: 9px; color: var(--faint); background: oklch(0.2 0.013 39); transition: border-color 160ms ease, background 160ms ease; }
.search-field:focus-within { border-color: oklch(0.49 0.06 41); background: var(--surface-raised); }
main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: auto; scrollbar-color: oklch(0.34 0.016 275) transparent; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: 18px; height: 68px; padding: 0 34px; background: oklch(0.13 0.008 275 / 0.94); border-bottom: 1px solid oklch(0.28 0.012 275 / 0.52); backdrop-filter: blur(18px); }
.search-field { display: flex; width: min(380px, 48vw); align-items: center; gap: 9px; padding: 8px 11px; border: 1px solid transparent; border-radius: 9px; color: var(--faint); background: oklch(0.19 0.01 275); transition: border-color 160ms ease, background 160ms ease; }
.search-field:focus-within { border-color: oklch(0.5 0.055 255); background: var(--surface-raised); }
.search-field input { width: 100%; padding: 0; border: 0; outline: 0; color: var(--ink); background: transparent; font-size: 0.82rem; }
.search-field input::placeholder { color: var(--faint); }
.search-field button { display: grid; padding: 2px; border: 0; background: transparent; color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
.avatar { display: grid; width: 31px; height: 31px; margin-left: 4px; place-items: center; border: 0; border-radius: 50%; color: oklch(0.21 0.03 40); background: oklch(0.77 0.075 61); font-size: 0.73rem; font-weight: 700; }
.avatar { display: grid; width: 31px; height: 31px; margin-left: 4px; place-items: center; border: 0; border-radius: 50%; color: oklch(0.2 0.012 275); background: oklch(0.78 0.018 275); font-size: 0.73rem; font-weight: 700; }
.menu-button { display: none !important; }
.icon-button { display: inline-grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 8px; color: var(--muted); background: transparent; transition: color 150ms ease, background 150ms ease, transform 150ms ease; }
.icon-button:hover { color: var(--ink); background: oklch(0.3 0.015 40 / 0.58); }
.icon-button:hover { color: var(--ink); background: oklch(0.3 0.014 275 / 0.58); }
.icon-button:active { transform: scale(0.94); }
.icon-button.is-active { color: var(--accent-bright); background: oklch(0.69 0.135 39 / 0.12); }
.icon-button.is-active { color: var(--accent-bright); background: oklch(0.68 0.17 28 / 0.12); }
.library-view { padding: 26px 34px 70px; }
.hero { position: relative; display: grid; min-height: 318px; grid-template-columns: minmax(0, 1fr) minmax(280px, 40%); align-items: center; overflow: hidden; margin-bottom: 45px; padding: 42px 58px; border-radius: 20px; background: oklch(0.26 0.045 36); box-shadow: 0 24px 80px oklch(0.06 0.02 35 / 0.26); isolation: isolate; }
.hero::before { position: absolute; z-index: -1; inset: 0; background: radial-gradient(circle at 86% 20%, oklch(0.67 0.11 37 / 0.32), transparent 34%), linear-gradient(118deg, oklch(0.275 0.055 35), oklch(0.205 0.026 42)); content: ""; }
.hero { position: relative; display: grid; min-height: 318px; grid-template-columns: minmax(0, 1fr) minmax(280px, 40%); align-items: center; overflow: hidden; margin-bottom: 45px; padding: 42px 58px; border: 1px solid oklch(0.32 0.018 275 / 0.7); border-radius: 20px; background: oklch(0.21 0.014 275); box-shadow: 0 24px 80px oklch(0.06 0.018 275 / 0.32); isolation: isolate; }
.hero::before { position: absolute; z-index: -1; inset: 0; background: radial-gradient(circle at 86% 20%, oklch(0.46 0.055 268 / 0.25), transparent 34%), linear-gradient(118deg, oklch(0.235 0.02 275), oklch(0.17 0.012 275)); content: ""; }
.hero-copy { position: relative; z-index: 2; max-width: 540px; }
.eyebrow { display: inline-flex; align-items: center; gap: 7px; color: var(--accent-bright); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.hero h1 { max-width: 500px; margin: 12px 0 13px; color: oklch(0.95 0.014 59); font-size: clamp(2.4rem, 4.4vw, 4.2rem); font-weight: 600; letter-spacing: -0.06em; line-height: 0.98; }
.hero p { max-width: 51ch; margin: 0; color: oklch(0.76 0.018 51); font-size: 0.96rem; line-height: 1.62; }
.hero h1 { max-width: 500px; margin: 12px 0 13px; color: oklch(0.95 0.008 275); font-size: clamp(2.4rem, 4.4vw, 4.2rem); font-weight: 600; letter-spacing: -0.06em; line-height: 0.98; }
.hero p { max-width: 51ch; margin: 0; color: oklch(0.75 0.012 275); font-size: 0.96rem; line-height: 1.62; }
.hero-actions { display: flex; gap: 10px; margin-top: 27px; }
.button-primary, .button-secondary, .button-quiet { display: inline-flex; min-height: 39px; align-items: center; justify-content: center; gap: 8px; padding: 0 15px; border-radius: 9px; font-size: 0.78rem; font-weight: 700; transition: transform 150ms ease, background 150ms ease, border-color 150ms ease; }
.button-primary { border: 1px solid var(--accent); color: oklch(0.18 0.02 37); background: var(--accent-bright); }
.button-primary--light { border-color: oklch(0.95 0.01 60); color: oklch(0.18 0.02 37); background: oklch(0.95 0.01 60); }
.button-primary:hover { transform: translateY(-1px); background: oklch(0.81 0.12 48); }
.button-primary { border: 1px solid var(--accent); color: oklch(0.16 0.012 275); background: var(--accent-bright); }
.button-primary--light { border-color: oklch(0.94 0.008 275); color: oklch(0.17 0.012 275); background: oklch(0.94 0.008 275); }
.button-primary:hover { transform: translateY(-1px); background: oklch(0.79 0.14 28); }
.button-secondary { border: 1px solid var(--line); color: var(--ink); background: transparent; }
.button-secondary:hover { border-color: oklch(0.48 0.025 42); background: var(--surface-raised); }
.button-quiet { border: 1px solid oklch(0.79 0.03 50 / 0.22); color: oklch(0.88 0.012 56); background: oklch(0.15 0.02 37 / 0.25); }
.button-quiet:hover { background: oklch(0.74 0.03 50 / 0.13); }
.button-secondary:hover { border-color: oklch(0.48 0.022 275); background: var(--surface-raised); }
.button-quiet { border: 1px solid oklch(0.79 0.018 275 / 0.22); color: oklch(0.88 0.01 275); background: oklch(0.15 0.012 275 / 0.3); }
.button-quiet:hover { background: oklch(0.74 0.02 275 / 0.13); }
.hero-art { position: relative; display: grid; min-height: 236px; place-items: center; }
.hero-art .cover { position: relative; z-index: 2; transform: rotate(3deg); box-shadow: -20px 28px 70px oklch(0.08 0.02 38 / 0.48); }
.orbit { position: absolute; width: 290px; height: 290px; border: 1px solid oklch(0.82 0.08 48 / 0.13); border-radius: 50%; }
.hero-art .cover { position: relative; z-index: 2; transform: rotate(3deg); box-shadow: -20px 28px 70px oklch(0.06 0.018 275 / 0.52); }
.orbit { position: absolute; width: 290px; height: 290px; border: 1px solid oklch(0.82 0.04 268 / 0.13); border-radius: 50%; }
.orbit-one { transform: translate(38px, -13px); }
.orbit-two { width: 210px; height: 210px; transform: translate(-65px, 18px); }
@@ -113,8 +113,8 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.album-tile > .icon-button { position: absolute; right: -5px; bottom: 12px; opacity: 0; }
.album-tile:hover > .icon-button, .album-tile:focus-within > .icon-button { opacity: 1; }
.cover-button { position: relative; display: block; width: 100%; padding: 0; border: 0; border-radius: 10px; background: transparent; }
.cover-button:hover .cover { transform: translateY(-4px); box-shadow: 0 18px 38px oklch(0.06 0.02 38 / 0.36); }
.cover-play { position: absolute; right: 10px; bottom: 10px; display: grid; width: 38px; height: 38px; place-items: center; border-radius: 50%; color: oklch(0.16 0.02 38); background: oklch(0.93 0.01 60); box-shadow: 0 8px 24px oklch(0.08 0.02 35 / 0.45); opacity: 0; transform: translateY(7px); transition: opacity 180ms ease, transform 180ms cubic-bezier(.22, 1, .36, 1); }
.cover-button:hover .cover { transform: translateY(-4px); box-shadow: 0 18px 38px oklch(0.05 0.018 275 / 0.42); }
.cover-play { position: absolute; right: 10px; bottom: 10px; display: grid; width: 38px; height: 38px; place-items: center; border-radius: 50%; color: oklch(0.16 0.012 275); background: oklch(0.93 0.008 275); box-shadow: 0 8px 24px oklch(0.06 0.018 275 / 0.5); opacity: 0; transform: translateY(7px); transition: opacity 180ms ease, transform 180ms cubic-bezier(.22, 1, .36, 1); }
.cover-button:hover .cover-play, .cover-button:focus-visible .cover-play { opacity: 1; transform: translateY(0); }
.cover { position: relative; display: grid; aspect-ratio: 1; overflow: hidden; place-items: center; border-radius: 9px; isolation: isolate; transition: transform 180ms cubic-bezier(.22, 1, .36, 1), box-shadow 180ms ease; }
@@ -134,9 +134,9 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.cover--sand { background: radial-gradient(circle at 68% 30%, oklch(0.86 0.065 84), transparent 22%), linear-gradient(145deg, oklch(0.67 0.08 74), oklch(0.29 0.045 53)); }
.track-list { overflow: hidden; border-top: 1px solid var(--line); }
.track-row { display: grid; grid-template-columns: minmax(245px, 1.5fr) minmax(130px, 1fr) 34px 48px 34px; min-height: 57px; align-items: center; border-bottom: 1px solid oklch(0.28 0.015 40 / 0.55); color: var(--muted); transition: background 140ms ease; }
.track-row:hover, .track-row:focus-within { background: oklch(0.21 0.015 39 / 0.72); }
.track-row.is-current { color: var(--accent-bright); background: oklch(0.23 0.025 39 / 0.62); }
.track-row { display: grid; grid-template-columns: minmax(245px, 1.5fr) minmax(130px, 1fr) 34px 48px 34px; min-height: 57px; align-items: center; border-bottom: 1px solid oklch(0.28 0.012 275 / 0.58); color: var(--muted); transition: background 140ms ease; }
.track-row:hover, .track-row:focus-within { background: oklch(0.2 0.012 275 / 0.78); }
.track-row.is-current { color: var(--accent-bright); background: oklch(0.22 0.022 275 / 0.7); }
.track-main { display: grid; min-width: 0; height: 100%; grid-template-columns: 31px 42px minmax(0, 1fr); align-items: center; gap: 11px; padding: 7px 12px 7px 0; border: 0; color: inherit; background: transparent; text-align: left; }
.track-main span:last-child { min-width: 0; }
.track-main strong, .track-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -156,21 +156,40 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.settings-view { width: min(760px, calc(100% - 64px)); margin: 70px auto 120px; }
.settings-view h1 { margin: 10px 0 9px; font-size: 2.35rem; font-weight: 600; letter-spacing: -0.055em; }
.settings-view > p { max-width: 58ch; margin: 0 0 32px; color: var(--muted); line-height: 1.6; }
.source-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.source-switch > button { display: flex; min-width: 0; align-items: center; gap: 12px; padding: 14px 15px; border: 1px solid var(--line); border-radius: 12px; color: var(--muted); background: oklch(0.16 0.009 275); text-align: left; transition: border-color 150ms ease, background 150ms ease, color 150ms ease; }
.source-switch > button:hover { color: var(--ink); background: oklch(0.19 0.012 275); }
.source-switch > button[aria-pressed="true"] { border-color: oklch(0.53 0.065 28); color: var(--ink); background: oklch(0.22 0.022 275); box-shadow: inset 0 0 0 1px oklch(0.68 0.17 28 / 0.12); }
.source-switch > button[aria-pressed="true"] > svg { color: var(--accent-bright); }
.source-switch span { min-width: 0; }
.source-switch strong, .source-switch small { display: block; }
.source-switch strong { font-size: 0.79rem; }
.source-switch small { margin-top: 3px; color: var(--faint); font-size: 0.65rem; }
.connection-form { display: grid; gap: 20px; padding: 28px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
.connection-form label { display: grid; gap: 8px; color: var(--muted); font-size: 0.71rem; font-weight: 600; }
.connection-form input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: oklch(0.145 0.012 38); font-size: 0.82rem; }
.connection-form input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px oklch(0.82 0.115 78 / 0.09); }
.connection-form input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: oklch(0.13 0.008 275); font-size: 0.82rem; }
.connection-form input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px oklch(0.78 0.12 255 / 0.1); }
.managed-heading { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding-bottom: 19px; border-bottom: 1px solid var(--line); }
.managed-icon { display: grid; width: 40px; height: 40px; place-items: center; border: 1px solid oklch(0.42 0.04 28); border-radius: 11px; color: var(--accent-bright); background: oklch(0.68 0.17 28 / 0.09); }
.managed-heading strong { font-size: 0.84rem; }
.managed-heading p { margin: 4px 0 0; color: var(--faint); font-size: 0.7rem; }
.managed-heading > span { padding: 4px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--faint); font-size: 0.59rem; font-variant-numeric: tabular-nums; }
.folder-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.folder-field input { overflow: hidden; text-overflow: ellipsis; }
.folder-field .button-secondary { white-space: nowrap; }
.field-hint { color: var(--faint); font-size: 0.65rem; font-weight: 400; line-height: 1.5; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-actions { display: flex; justify-content: flex-end; gap: 9px; }
.connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.015 39); font-size: 0.73rem; }
.connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.012 275); font-size: 0.73rem; }
.connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); }
.connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); }
.button-danger { margin-right: auto; color: oklch(0.78 0.1 25); }
.privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); }
.privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); }
.privacy-note strong { color: var(--ink); font-size: 0.78rem; }
.privacy-note p { max-width: 64ch; margin: 5px 0 0; font-size: 0.72rem; line-height: 1.55; }
.player-bar { position: fixed; z-index: 50; right: 0; bottom: 0; left: 0; display: grid; height: var(--player-height); grid-template-columns: minmax(210px, 1fr) minmax(320px, 1.5fr) minmax(210px, 1fr); align-items: center; gap: 18px; padding: 10px 21px; border-top: 1px solid oklch(0.37 0.025 42 / 0.75); background: oklch(0.175 0.016 38); box-shadow: 0 -16px 45px oklch(0.06 0.018 35 / 0.22); }
.player-bar { position: fixed; z-index: 50; right: 0; bottom: 0; left: 0; display: grid; height: var(--player-height); grid-template-columns: minmax(210px, 1fr) minmax(320px, 1.5fr) minmax(210px, 1fr); align-items: center; gap: 18px; padding: 10px 21px; border-top: 1px solid oklch(0.36 0.018 275 / 0.78); background: oklch(0.16 0.01 275); box-shadow: 0 -16px 45px oklch(0.05 0.016 275 / 0.28); }
.now-playing { display: flex; min-width: 0; align-items: center; gap: 11px; padding: 0; border: 0; background: transparent; text-align: left; }
.now-playing > span { min-width: 0; }
.now-playing strong, .now-playing small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -180,22 +199,22 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.now-playing > svg.filled-heart { color: var(--accent-bright); }
.transport { display: grid; justify-items: center; gap: 7px; }
.transport-buttons { display: flex; align-items: center; gap: 8px; }
.play-button { display: grid; width: 37px; height: 37px; margin: 0 3px; place-items: center; padding: 0; border: 0; border-radius: 50%; color: oklch(0.18 0.02 38); background: oklch(0.93 0.01 58); transition: transform 150ms ease, background 150ms ease; }
.play-button:hover { transform: scale(1.05); background: oklch(0.98 0.008 58); }
.play-button { display: grid; width: 37px; height: 37px; margin: 0 3px; place-items: center; padding: 0; border: 0; border-radius: 50%; color: oklch(0.17 0.012 275); background: oklch(0.93 0.008 275); transition: transform 150ms ease, background 150ms ease; }
.play-button:hover { transform: scale(1.05); background: oklch(0.98 0.006 275); }
.scrubber { display: grid; width: min(520px, 100%); grid-template-columns: 35px minmax(80px, 1fr) 35px; align-items: center; gap: 8px; color: var(--faint); font-size: 0.59rem; font-variant-numeric: tabular-nums; }
.scrubber span:last-child { text-align: right; }
input[type="range"] { width: 100%; height: 3px; margin: 0; appearance: none; border-radius: 99px; background: linear-gradient(to right, var(--ink) var(--range-progress, 0%), oklch(0.37 0.02 42) var(--range-progress, 0%)); }
input[type="range"] { width: 100%; height: 3px; margin: 0; appearance: none; border-radius: 99px; background: linear-gradient(to right, var(--ink) var(--range-progress, 0%), oklch(0.37 0.016 275) var(--range-progress, 0%)); }
input[type="range"]::-webkit-slider-thumb { width: 10px; height: 10px; appearance: none; border: 0; border-radius: 50%; background: var(--ink); opacity: 0; transition: opacity 140ms ease; }
input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visible::-webkit-slider-thumb { opacity: 1; }
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; color: var(--muted); }
.player-tools input { max-width: 90px; }
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); padding: 25px; border-left: 1px solid var(--line); background: oklch(0.185 0.016 39); box-shadow: -24px 0 70px oklch(0.06 0.018 36 / 0.36); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); padding: 25px; border-left: 1px solid var(--line); background: oklch(0.17 0.01 275); box-shadow: -24px 0 70px oklch(0.05 0.016 275 / 0.4); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
.queue-panel.is-open { transform: translateX(0); }
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; }
.queue-header h2 { margin: 5px 0 0; font-size: 1.35rem; letter-spacing: -0.04em; }
.queue-now { display: grid; margin: 28px 0 25px; }
.queue-now .cover { box-shadow: 0 22px 45px oklch(0.06 0.02 38 / 0.34); }
.queue-now .cover { box-shadow: 0 22px 45px oklch(0.05 0.018 275 / 0.4); }
.queue-now > span { margin-top: 15px; color: var(--accent-bright); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
.queue-now > strong { margin-top: 5px; font-size: 0.93rem; }
.queue-now > small { margin-top: 3px; color: var(--faint); font-size: 0.72rem; }
@@ -222,7 +241,7 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
.app-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { position: fixed; top: 0; bottom: var(--player-height); left: 0; width: 230px; transform: translateX(-105%); transition: transform 220ms cubic-bezier(.22, 1, .36, 1); }
.sidebar.is-open { transform: translateX(0); }
.nav-scrim { position: fixed; z-index: 25; inset: 0 0 var(--player-height); display: block; border: 0; background: oklch(0.06 0.01 38 / 0.7); }
.nav-scrim { position: fixed; z-index: 25; inset: 0 0 var(--player-height); display: block; border: 0; background: oklch(0.05 0.01 275 / 0.74); }
.menu-button { display: inline-grid !important; }
.topbar { padding: 0 20px; }
.library-view { padding: 22px 20px 60px; }
@@ -262,6 +281,8 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
.settings-view { width: calc(100% - 28px); margin-top: 36px; }
.settings-view h1 { font-size: 1.9rem; }
.connection-form { padding: 20px; }
.source-switch { grid-template-columns: 1fr; }
.folder-field { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.form-actions { flex-direction: column-reverse; }
.form-actions button { width: 100%; }