diff --git a/.gitignore b/.gitignore index dec3439..9176fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ node_modules/ dist/ src-tauri/target/ src-tauri/binaries/navidrome-* +src-tauri/binaries/ffmpeg-* src-tauri/resources/navidrome-LICENSE +src-tauri/resources/ffmpeg-LICENSE +src-tauri/resources/ffmpeg-README .DS_Store *.log *.tsbuildinfo diff --git a/README.md b/README.md index b1dcb62..bbabba7 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ Resonant is a desktop-first music player for personal collections. The desktop a ## What works in v0.1 -- A responsive library home with albums, tracks, favorites, search, and queue management +- A responsive library home with paginated newest albums, a complete Songs view, favorites, search, and queue management - 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 +- OpenSubsonic token authentication, connection testing, complete album/song pagination, artwork, and streaming URLs +- Managed Navidrome 0.61.2 and FFmpeg sidecars, with native folder selection, first-user setup, transcoding, LAN access, 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 -The server password is held only in memory. Resonant remembers the server address and username locally, but secure credential persistence is intentionally deferred until the desktop keychain integration is added. +Local and remote server passwords are stored through the operating system credential store. Resonant restores the selected library automatically on the next desktop launch. ## Run it @@ -33,7 +33,7 @@ To run the desktop shell with the bundled server: 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. +The first desktop run downloads the pinned official Navidrome binary for the current Rust target, verifies its release digest, and prepares the platform's pinned FFmpeg static binary. Both executables and their license notices are bundled into packaged apps, not committed to this repository. The generic Tauri entry point also prepares the sidecar automatically, so `npm run tauri dev` and `npm run tauri build` are supported as well. @@ -47,10 +47,10 @@ npm run check 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. The app clears its demo content while Navidrome scans and waits for real indexed tracks before showing the library. +- **This computer:** choose a music folder and a library password. Resonant starts its bundled Navidrome process on port `4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. It shows the LAN address other computers can use, clears demo content while scanning, and waits for real indexed tracks before showing the library. - **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. +Passwords are saved in Keychain on macOS, Credential Manager on Windows, or Secret Service on Linux. Browser development can preview the interface and connect to servers that allow the development origin, but secure credential persistence, native folder selection, and the managed server require the Tauri desktop app. ## Architecture @@ -65,7 +65,8 @@ Resonant desktop client └── Tauri shell └── managed Navidrome sidecar ├── official, pinned, checksum-verified binary - ├── loopback-only HTTP server + ├── bundled FFmpeg transcoder + ├── private-network HTTP server └── app-private database and logs Navidrome or another OpenSubsonic server @@ -76,19 +77,17 @@ 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 +## Network boundary -- 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. +The managed server is reachable by devices on the same LAN and is protected by the library password. It intentionally does not configure routers, public DNS, certificates, or internet exposure. Do not forward port `4533` directly to the public internet; remote access should use a trusted VPN or a TLS reverse proxy. ## 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. +1. Move remote requests into Tauri's HTTP plugin for stricter native networking control. +2. Add album and artist detail routes. 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. Add an explicit remote-access flow without weakening the local-only default. +5. Add a guided VPN/TLS remote-access flow without silently exposing the library to the internet. 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). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fc5e25d..9d42ef5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,3 +10,14 @@ Resonant desktop builds bundle an unmodified Navidrome 0.61.2 executable as the - 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. + +## FFmpeg + +Resonant desktop builds bundle a platform-specific FFmpeg executable for media transcoding. The binary is supplied by `ffmpeg-static` 5.3.0 and reports FFmpeg 6.0 on Apple Silicon. + +- Project: https://ffmpeg.org/ +- Source: https://github.com/FFmpeg/FFmpeg/tree/n6.0 +- Binary packaging: https://github.com/eugeneware/ffmpeg-static/tree/5.3.0 +- License: GNU General Public License v3.0 or later + +The exact upstream binary build information and license copied by `ffmpeg-static` are included in every packaged Resonant app. diff --git a/package-lock.json b/package-lock.json index a6d6e79..2cb1599 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/spark-md5": "^3.0.5", "@vitejs/plugin-react": "latest", "esbuild": "latest", + "ffmpeg-static": "5.3.0", "jsdom": "latest", "typescript": "latest", "vite": "^8.1.4", @@ -279,6 +280,22 @@ "node": ">=20.19.0" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1952,6 +1969,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2007,6 +2037,20 @@ "require-from-string": "^2.0.2" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2017,6 +2061,22 @@ "node": ">=18" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2066,6 +2126,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2114,6 +2192,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2201,6 +2289,23 @@ } } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "dev": true, + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2229,6 +2334,37 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2239,6 +2375,13 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2613,6 +2756,13 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -2646,6 +2796,12 @@ "node": ">=12.20.0" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", + "dev": true + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2731,6 +2887,16 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2770,6 +2936,21 @@ "license": "MIT", "peer": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -2828,6 +3009,27 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2884,6 +3086,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3002,6 +3214,13 @@ "license": "0BSD", "optional": true }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -3054,6 +3273,13 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/package.json b/package.json index fc5c263..487414b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/spark-md5": "^3.0.5", "@vitejs/plugin-react": "latest", "esbuild": "latest", + "ffmpeg-static": "5.3.0", "jsdom": "latest", "typescript": "latest", "vite": "^8.1.4", diff --git a/scripts/prepare-navidrome.mjs b/scripts/prepare-navidrome.mjs index 8a5ef27..b46daf0 100644 --- a/scripts/prepare-navidrome.mjs +++ b/scripts/prepare-navidrome.mjs @@ -4,6 +4,7 @@ import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:f import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import ffmpegPath from "ffmpeg-static"; const VERSION = "0.61.2"; const RELEASE_API = `https://api.github.com/repos/navidrome/navidrome/releases/tags/v${VERSION}`; @@ -26,13 +27,17 @@ if (!archiveName) { const extension = targetTriple.includes("windows") ? ".exe" : ""; const outputDirectory = join(projectRoot, "src-tauri/binaries"); const outputPath = join(outputDirectory, `navidrome-${targetTriple}${extension}`); +const ffmpegOutputPath = join(outputDirectory, `ffmpeg-${targetTriple}${extension}`); const resourceDirectory = join(projectRoot, "src-tauri/resources"); const licensePath = join(resourceDirectory, "navidrome-LICENSE"); +const ffmpegLicensePath = join(resourceDirectory, "ffmpeg-LICENSE"); +const ffmpegReadmePath = join(resourceDirectory, "ffmpeg-README"); await mkdir(outputDirectory, { recursive: true }); await mkdir(resourceDirectory, { recursive: true }); let binaryReady = false; let licenseReady = false; +let ffmpegReady = false; try { await readFile(outputPath); binaryReady = true; @@ -45,6 +50,20 @@ try { } catch { // Include Navidrome's GPL license in the packaged app when missing. } +try { + await readFile(ffmpegOutputPath); + ffmpegReady = true; +} catch { + // Copy the pinned npm-provided binary below when missing. +} + +if (!ffmpegPath) throw new Error(`ffmpeg-static does not provide a binary for ${process.platform}-${process.arch}.`); +if (!ffmpegReady) { + await copyFile(ffmpegPath, ffmpegOutputPath); + if (!extension) await chmod(ffmpegOutputPath, 0o755); +} +await copyFile(join(dirname(ffmpegPath), "ffmpeg.LICENSE"), ffmpegLicensePath); +await copyFile(join(dirname(ffmpegPath), "ffmpeg.README"), ffmpegReadmePath); const headers = { Accept: "application/vnd.github+json", "User-Agent": "Resonant-sidecar-preparer" }; if (!licenseReady) { @@ -53,7 +72,7 @@ if (!licenseReady) { await writeFile(licensePath, await licenseResponse.text()); } if (binaryReady) { - process.stdout.write(`Navidrome ${VERSION} sidecar is ready for ${targetTriple}.\n`); + process.stdout.write(`Navidrome ${VERSION} and FFmpeg sidecars are ready for ${targetTriple}.\n`); process.exit(0); } @@ -84,4 +103,4 @@ try { await rm(workingDirectory, { recursive: true, force: true }); } -process.stdout.write(`Downloaded and verified Navidrome ${VERSION} for ${targetTriple}.\n`); +process.stdout.write(`Downloaded and verified Navidrome ${VERSION}; FFmpeg is ready for ${targetTriple}.\n`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 34f0582..5c1ad6e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +58,148 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "atk" version = "0.18.2" @@ -133,6 +286,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -142,6 +304,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -266,6 +441,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.67" @@ -321,6 +505,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -331,6 +525,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -546,6 +749,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -710,6 +914,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -737,6 +968,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -858,6 +1110,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1212,12 +1477,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1485,6 +1774,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1627,6 +1926,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1685,6 +2005,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1810,12 +2136,75 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2066,6 +2455,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2101,6 +2500,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2189,6 +2594,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2234,6 +2650,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2460,6 +2890,7 @@ dependencies = [ name = "resonant" version = "0.1.0" dependencies = [ + "keyring", "serde", "serde_json", "tauri", @@ -2507,6 +2938,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2579,6 +3023,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -2943,6 +3429,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3357,6 +3849,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3660,9 +4165,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3712,6 +4229,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4183,6 +4711,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -4603,6 +5144,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4624,6 +5237,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4662,3 +5281,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ecee62e..501064b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +keyring = "4.1" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = [] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9aaf35..3d294b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ +use keyring::{Entry, Error as KeyringError}; use serde::Serialize; -use std::{fs, path::PathBuf, sync::Mutex}; +use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex}; use tauri::{AppHandle, Manager, State, WindowEvent}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, @@ -8,6 +9,9 @@ use tauri_plugin_shell::{ const NAVIDROME_VERSION: &str = "0.61.2"; const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533"; +const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant"; +const LOCAL_CREDENTIAL: &str = "local-admin"; +const REMOTE_CREDENTIAL: &str = "remote-server"; struct ManagedServer { child: CommandChild, @@ -23,19 +27,95 @@ struct ManagedServerState(Mutex>); struct ManagedServerStatus { running: bool, url: String, + lan_urls: Vec, music_folder: Option, version: String, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SavedCredentials { + #[serde(skip_serializing_if = "Option::is_none")] + local_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remote_password: Option, +} + +fn credential_entry(account: &str) -> Result { + Entry::new(CREDENTIAL_SERVICE, account) + .map_err(|error| format!("The system credential store is unavailable: {error}")) +} + +fn read_password(account: &str) -> Result, String> { + match credential_entry(account)?.get_password() { + Ok(password) => Ok(Some(password)), + Err(KeyringError::NoEntry) => Ok(None), + Err(error) => Err(format!("The saved credential could not be read: {error}")), + } +} + +fn write_password(account: &str, password: &str) -> Result<(), String> { + credential_entry(account)? + .set_password(password) + .map_err(|error| format!("The credential could not be saved securely: {error}")) +} + +fn discover_lan_urls() -> Vec { + let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else { + return Vec::new(); + }; + if socket.connect("1.1.1.1:80").is_err() { + return Vec::new(); + } + let Ok(address) = socket.local_addr() else { + return Vec::new(); + }; + if address.ip().is_loopback() || !address.ip().is_ipv4() { + return Vec::new(); + } + vec![format!("http://{}:4533", address.ip())] +} + +fn bundled_sidecar_path(name: &str) -> Result { + let filename = if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + }; + let executable = std::env::current_exe() + .map_err(|error| format!("The Resonant executable path is unavailable: {error}"))?; + Ok(executable + .parent() + .ok_or_else(|| "The Resonant executable folder is unavailable.".to_string())? + .join(filename)) +} + fn status_for(server: Option<&ManagedServer>) -> ManagedServerStatus { ManagedServerStatus { running: server.is_some(), url: MANAGED_SERVER_URL.to_string(), + lan_urls: discover_lan_urls(), music_folder: server.map(|server| server.music_folder.clone()), version: NAVIDROME_VERSION.to_string(), } } +#[tauri::command] +fn load_saved_credentials() -> Result { + Ok(SavedCredentials { + local_password: read_password(LOCAL_CREDENTIAL)?, + remote_password: read_password(REMOTE_CREDENTIAL)?, + }) +} + +#[tauri::command] +fn save_remote_password(password: String) -> Result<(), String> { + if password.is_empty() { + return Err("Enter the server password before saving it.".to_string()); + } + write_password(REMOTE_CREDENTIAL, &password) +} + fn stop_server(state: &ManagedServerState) -> Result<(), String> { let mut server = state .0 @@ -78,6 +158,7 @@ fn start_managed_server( if !music_path.is_dir() { return Err("Choose a folder that contains your music.".to_string()); } + write_password(LOCAL_CREDENTIAL, &password)?; stop_server(&state)?; @@ -89,17 +170,23 @@ fn start_managed_server( 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 ffmpeg_path = bundled_sidecar_path("ffmpeg")?; + if !ffmpeg_path.is_file() { + return Err("The bundled FFmpeg transcoder is unavailable.".to_string()); + } 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_ADDRESS", "0.0.0.0") .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_FFMPEGPATH", &ffmpeg_path) + .env("ND_TRANSCODING_MAXCONCURRENT", "2") .env("ND_ENABLEINSIGHTSCOLLECTOR", "false") .env("ND_ENABLEEXTERNALSERVICES", "false") .env("ND_DEVAUTOCREATEADMINPASSWORD", password); @@ -140,6 +227,7 @@ fn start_managed_server( Ok(ManagedServerStatus { running: true, url: MANAGED_SERVER_URL.to_string(), + lan_urls: discover_lan_urls(), music_folder: Some(music_folder), version: NAVIDROME_VERSION.to_string(), }) @@ -161,6 +249,8 @@ pub fn run() { .manage(ManagedServerState::default()) .invoke_handler(tauri::generate_handler![ managed_server_status, + load_saved_credentials, + save_remote_password, start_managed_server, stop_managed_server ]) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e10b258..148a7b1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,7 +28,11 @@ "bundle": { "active": true, "targets": "all", - "externalBin": ["binaries/navidrome"], - "resources": ["resources/navidrome-LICENSE"] + "externalBin": ["binaries/navidrome", "binaries/ffmpeg"], + "resources": [ + "resources/navidrome-LICENSE", + "resources/ffmpeg-LICENSE", + "resources/ffmpeg-README" + ] } } diff --git a/src/App.tsx b/src/App.tsx index b47d365..45ac2c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,11 +30,13 @@ import { } from "lucide-react"; import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { usePlayer } from "./hooks/usePlayer"; -import { pollForLibrary } from "./lib/librarySync"; +import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { chooseMusicFolder, getManagedServerStatus, + getSavedCredentials, isDesktopRuntime, + saveRemotePassword, startManagedServer, stopManagedServer, type ManagedServerStatus, @@ -59,6 +61,7 @@ function IconButton({ label, children, onClick, active = false, className = "" } const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ { view: "home", label: "Listen now", icon: }, { view: "albums", label: "Albums", icon: }, + { view: "songs", label: "Songs", icon: }, { view: "artists", label: "Artists", icon: }, { view: "favorites", label: "Favorites", icon: }, ]; @@ -93,15 +96,6 @@ export default function App() { const player = usePlayer(libraryTracks); const hasLibraryTracks = libraryTracks.length > 0; - 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; @@ -122,11 +116,23 @@ export default function App() { const fetchServerLibrary = async (config: ServerConfig): Promise => { 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 [serverAlbums, serverSongs] = await Promise.all([ + fetchAllPages(async (offset, size) => { + const response = await requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "newest", size, offset }); + return response.albumList2?.album ?? []; + }), + fetchAllPages(async (offset, size) => { + const response = await requestSubsonic<{ searchResult3?: { song?: ServerSong[] } }>(config, "search3", { + query: "", + artistCount: 0, + albumCount: 0, + songCount: size, + songOffset: offset, + }); + return response.searchResult3?.song ?? []; + }), ]); - const albums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({ + const albums = serverAlbums.map((album, index): Album => ({ id: album.id, title: album.name, artist: album.artist ?? "Unknown artist", @@ -134,7 +140,7 @@ export default function App() { coverTone: tones[index % tones.length], coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined, })); - const tracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({ + const tracks = serverSongs.map((song, index): Track => ({ id: song.id, title: song.title, artist: song.artist ?? "Unknown artist", @@ -172,6 +178,7 @@ export default function App() { setConnectionMessage("Reaching your library…"); try { await loadServerLibrary(server, `Connected as ${server.username}`); + await saveRemotePassword(server.password); localStorage.setItem("resonant.serverUrl", server.url); localStorage.setItem("resonant.username", server.username); localStorage.setItem("resonant.sourceMode", "remote"); @@ -191,14 +198,13 @@ export default function App() { if (musicFolder) setLocalSetup((current) => ({ ...current, musicFolder })); }; - const startLocalLibrary = async (event: FormEvent) => { - event.preventDefault(); + const runLocalLibrary = async (musicFolder: string, password: string) => { setConnectionStatus("connecting"); - setConnectionMessage("Starting your private music server…"); + setConnectionMessage("Starting your music server…"); try { - const status = await startManagedServer(localSetup.musicFolder, localSetup.password); + const status = await startManagedServer(musicFolder, password); setManagedStatus(status); - const config = { url: status.url, username: "admin", password: localSetup.password }; + const config = { url: status.url, username: "admin", password }; let lastError: unknown; for (let attempt = 0; attempt < 20; attempt += 1) { @@ -221,10 +227,12 @@ export default function App() { setLibraryPhase("scanning"); setConnectionMessage("Scanning your music folder for real tracks…"); setView("home"); - localStorage.setItem("resonant.musicFolder", localSetup.musicFolder); + localStorage.setItem("resonant.musicFolder", musicFolder); localStorage.setItem("resonant.sourceMode", "local"); - const library = await pollForLibrary(() => fetchServerLibrary(config)); + const library = await pollForLibrary(() => fetchServerLibrary(config), { + isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0, + }); if (!library) { setLibraryPhase("empty"); setConnectionStatus("error"); @@ -244,6 +252,11 @@ export default function App() { } }; + const startLocalLibrary = async (event: FormEvent) => { + event.preventDefault(); + await runLocalLibrary(localSetup.musicFolder, localSetup.password); + }; + const stopLocalLibrary = async () => { try { const status = await stopManagedServer(); @@ -260,6 +273,41 @@ export default function App() { } }; + useEffect(() => { + let active = true; + const restoreLibrary = async () => { + try { + const [status, credentials] = await Promise.all([ + getManagedServerStatus(), + getSavedCredentials(), + ]); + if (!active) return; + setManagedStatus(status); + + const musicFolder = status.musicFolder ?? localStorage.getItem("resonant.musicFolder") ?? ""; + const localPassword = credentials.localPassword ?? ""; + const remotePassword = credentials.remotePassword ?? ""; + setLocalSetup({ musicFolder, password: localPassword }); + setServer((current) => ({ ...current, password: remotePassword })); + + const savedMode = localStorage.getItem("resonant.sourceMode"); + if (savedMode === "local" && musicFolder && localPassword) { + await runLocalLibrary(musicFolder, localPassword); + } else if (savedMode === "remote" && server.url && server.username && remotePassword) { + await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`); + } + } catch (error) { + if (!active) return; + setConnectionStatus("error"); + setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored."); + } + }; + void restoreLibrary(); + return () => { + active = false; + }; + }, []); + const goTo = (next: View) => { setView(next); setMobileNav(false); @@ -308,10 +356,11 @@ export default function App() { {sourceMode === "local" ? (
-
Your music, served privately

Resonant includes Navidrome and listens only on this computer.

v{managedStatus?.version ?? "0.61.2"}
+
Your music, ready on every device

Resonant includes Navidrome and FFmpeg, with private-network access built in.

v{managedStatus?.version ?? "0.61.2"}
- +
{connectionMessage}
+ {managedStatus?.running && managedStatus.lanUrls.length > 0 &&
Listen from another computer

Use this address in Resonant or any OpenSubsonic app on the same private network. Sign in as admin with your library password.

{managedStatus.lanUrls.map((url) => {url})}
}
{managedStatus?.running && } @@ -326,7 +375,7 @@ export default function App() {
)} -
{sourceMode === "local" ? "Private by default" : "Your server stays yours"}

{sourceMode === "local" ? "The managed server binds to 127.0.0.1, keeps its database in Resonant’s app data, and stops when Resonant closes." : "Resonant speaks OpenSubsonic directly. Your password stays in memory for this session."}

+
{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}

{sourceMode === "local" ? "The managed server accepts devices on your LAN, keeps its database in Resonant’s app data, and stops when Resonant closes. Avoid exposing port 4533 directly to the internet." : "Resonant speaks OpenSubsonic directly and saves the password in your operating system credential store."}

) : (
@@ -346,14 +395,14 @@ export default function App() { )} -
+ {view !== "songs" &&
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}

{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}

- {visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? 18 : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

} -
+ {visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

} +
}
-
{view === "favorites" ? "Kept close" : "A familiar thread"}

{view === "favorites" ? "Favorite tracks" : "Heavy rotation"}

{visibleTracks.length} tracks
-
{visibleTracks.slice(0, view === "home" && !query ? 6 : 30).map((track, index) =>
{track.album} toggleFavorite(track.id)}>{formatTime(track.duration)} player.addToQueue(track)}>
)}
+
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{visibleTracks.length} tracks
+
{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) =>
{track.album} toggleFavorite(track.id)}>{formatTime(track.duration)} player.addToQueue(track)}>
)}
}
diff --git a/src/lib/librarySync.test.ts b/src/lib/librarySync.test.ts index 35f010c..621cb9b 100644 --- a/src/lib/librarySync.test.ts +++ b/src/lib/librarySync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { pollForLibrary } from "./librarySync"; +import { fetchAllPages, pollForLibrary } from "./librarySync"; describe("pollForLibrary", () => { it("waits until Navidrome returns indexed tracks", async () => { @@ -24,4 +24,30 @@ describe("pollForLibrary", () => { await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull(); expect(load).toHaveBeenCalledTimes(3); }); + + it("can wait for albums and tracks to finish indexing together", async () => { + const load = vi + .fn() + .mockResolvedValueOnce({ albums: [], tracks: [{ id: "early" }] }) + .mockResolvedValueOnce({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] }); + + await expect(pollForLibrary<{ albums: { id: string }[]; tracks: { id: string }[] }>(load, { + attempts: 2, + wait: async () => undefined, + isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0, + })).resolves.toEqual({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] }); + }); +}); + +describe("fetchAllPages", () => { + it("loads successive pages until the server returns a partial page", async () => { + const loadPage = vi + .fn() + .mockResolvedValueOnce(["one", "two"]) + .mockResolvedValueOnce(["three"]); + + await expect(fetchAllPages(loadPage, 2)).resolves.toEqual(["one", "two", "three"]); + expect(loadPage).toHaveBeenNthCalledWith(1, 0, 2); + expect(loadPage).toHaveBeenNthCalledWith(2, 2, 2); + }); }); diff --git a/src/lib/librarySync.ts b/src/lib/librarySync.ts index d6ea934..fb974a8 100644 --- a/src/lib/librarySync.ts +++ b/src/lib/librarySync.ts @@ -2,22 +2,42 @@ export interface LibrarySnapshot { tracks: readonly TTrack[]; } -interface PollOptions { +export async function fetchAllPages( + loadPage: (offset: number, size: number) => Promise, + pageSize = 500, + maxPages = 100, +): Promise { + const items: T[] = []; + for (let page = 0; page < maxPages; page += 1) { + const batch = await loadPage(page * pageSize, pageSize); + items.push(...batch); + if (batch.length < pageSize) break; + } + return items; +} + +interface PollOptions { attempts?: number; delayMs?: number; wait?: (delayMs: number) => Promise; + isReady?: (snapshot: TSnapshot) => boolean; } const waitFor = (delayMs: number) => new Promise((resolve) => window.setTimeout(resolve, delayMs)); export async function pollForLibrary>( load: () => Promise, - { attempts = 120, delayMs = 1_000, wait = waitFor }: PollOptions = {}, + { + attempts = 120, + delayMs = 1_000, + wait = waitFor, + isReady = (snapshot) => snapshot.tracks.length > 0, + }: PollOptions = {}, ): Promise { for (let attempt = 0; attempt < attempts; attempt += 1) { try { const snapshot = await load(); - if (snapshot.tracks.length > 0) return snapshot; + if (isReady(snapshot)) return snapshot; } catch (error) { if (attempt === attempts - 1) throw error; } diff --git a/src/lib/managedServer.test.ts b/src/lib/managedServer.test.ts index 13d9410..5393516 100644 --- a/src/lib/managedServer.test.ts +++ b/src/lib/managedServer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getManagedServerStatus, isDesktopRuntime } from "./managedServer"; +import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer"; describe("managed server bridge", () => { it("reports the browser preview as a non-desktop runtime", () => { @@ -10,7 +10,12 @@ describe("managed server bridge", () => { await expect(getManagedServerStatus()).resolves.toMatchObject({ running: false, url: "http://127.0.0.1:4533", + lanUrls: [], version: "0.61.2", }); }); + + it("does not expose credentials in the browser preview", async () => { + await expect(getSavedCredentials()).resolves.toEqual({}); + }); }); diff --git a/src/lib/managedServer.ts b/src/lib/managedServer.ts index 9fc2996..43e246d 100644 --- a/src/lib/managedServer.ts +++ b/src/lib/managedServer.ts @@ -4,10 +4,16 @@ import { open } from "@tauri-apps/plugin-dialog"; export interface ManagedServerStatus { running: boolean; url: string; + lanUrls: string[]; musicFolder?: string; version: string; } +export interface SavedCredentials { + localPassword?: string; + remotePassword?: string; +} + export function isDesktopRuntime(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; } @@ -24,11 +30,21 @@ export async function chooseMusicFolder(): Promise { export async function getManagedServerStatus(): Promise { if (!isDesktopRuntime()) { - return { running: false, url: "http://127.0.0.1:4533", version: "0.61.2" }; + return { running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" }; } return invoke("managed_server_status"); } +export async function getSavedCredentials(): Promise { + if (!isDesktopRuntime()) return {}; + return invoke("load_saved_credentials"); +} + +export async function saveRemotePassword(password: string): Promise { + if (!isDesktopRuntime()) return; + await invoke("save_remote_password", { password }); +} + export async function startManagedServer(musicFolder: string, password: string): Promise { if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app."); return invoke("start_managed_server", { musicFolder, password }); diff --git a/src/styles.css b/src/styles.css index e4face6..e2b10f1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -195,6 +195,10 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut .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); } +.device-addresses { display: grid; gap: 6px; padding: 14px; border: 1px solid oklch(0.39 0.035 145); border-radius: 10px; background: oklch(0.21 0.025 145); } +.device-addresses strong { font-size: 0.75rem; } +.device-addresses p { margin: 0 0 3px; color: var(--muted); font-size: 0.68rem; line-height: 1.5; } +.device-addresses code { width: fit-content; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: oklch(0.84 0.08 145); background: oklch(0.14 0.012 275); font-size: 0.7rem; user-select: all; } .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); } diff --git a/src/types.ts b/src/types.ts index ca09ce7..f6b6885 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,4 +29,4 @@ export interface ServerConfig { } export type ConnectionStatus = "demo" | "connecting" | "connected" | "error"; -export type View = "home" | "albums" | "artists" | "favorites" | "settings"; +export type View = "home" | "albums" | "songs" | "artists" | "favorites" | "settings";