desktop app
112
desktop/README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Quintodrome Desktop
|
||||||
|
|
||||||
|
A [Tauri](https://tauri.app) desktop wrapper that hosts [Navidrome](https://www.navidrome.org)
|
||||||
|
as its own web app.
|
||||||
|
|
||||||
|
On launch the app:
|
||||||
|
|
||||||
|
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
|
||||||
|
(via its `/ping` health endpoint). If so, it reuses it as-is.
|
||||||
|
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
|
||||||
|
process**, pointed at the OS app-data/music directories, and waits for it to
|
||||||
|
become ready.
|
||||||
|
3. Loads the Navidrome UI in the webview.
|
||||||
|
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
|
||||||
|
(falls back to a hard kill after 3s).
|
||||||
|
|
||||||
|
## How to launch
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 0. one-time: make sure Node >= 24 is active (already pinned in .nvmrc)
|
||||||
|
nodenv local 24.15.0 # or `nvm use` / ensure `node --version` is v24+
|
||||||
|
|
||||||
|
# 1. build the Navidrome server + stage it as the sidecar
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
|
||||||
|
# 2. launch the app
|
||||||
|
cd desktop/src-tauri && cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
A window opens on the splash screen, the app starts Navidrome (or reuses a
|
||||||
|
running one), then loads the UI. Navidrome is reachable at
|
||||||
|
<http://127.0.0.1:4533> for the first-run admin setup.
|
||||||
|
|
||||||
|
To build a standalone `.app` instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh
|
||||||
|
# → desktop/src-tauri/target/release/bundle/macos/Quintodrome.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
desktop/
|
||||||
|
├── src/ # Splash screen shown while Navidrome boots
|
||||||
|
├── src-tauri/
|
||||||
|
│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle
|
||||||
|
│ ├── binaries/ # navidrome-<target-triple> sidecar (built, not committed)
|
||||||
|
│ ├── icons/ # App icons
|
||||||
|
│ ├── tauri.conf.json
|
||||||
|
│ └── Cargo.toml
|
||||||
|
└── scripts/
|
||||||
|
├── build.sh # Build navidrome + stage sidecar + tauri build
|
||||||
|
└── gen_icon.go # Regenerates the source icon (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (stable) with the `aarch64-apple-darwin` / `x86_64-apple-darwin` (or
|
||||||
|
matching host) target.
|
||||||
|
- Node.js >= 24 (the repo already pins this in `.nvmrc`; via `nodenv` you can
|
||||||
|
run `nodenv local 24.15.0`).
|
||||||
|
- Go >= 1.26 (for building the Navidrome binary).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the Navidrome server (`make build`), stages it under
|
||||||
|
`src-tauri/binaries/navidrome-<target-triple>` (with the executable bit set),
|
||||||
|
then runs `npx tauri build`. The packaged app lands in
|
||||||
|
`desktop/src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
|
To build the sidecar without running the full Tauri bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run (development)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. build + stage the sidecar binary
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
|
||||||
|
# 2. run the app
|
||||||
|
cd desktop/src-tauri && cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rust `build.rs` copies the staged sidecar next to the dev binary
|
||||||
|
(`target/debug/navidrome`) automatically, so plain `cargo run` works — no
|
||||||
|
`tauri dev` CLI needed.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables override the defaults:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --------------------------- | ------------------------ | ------------------------------- |
|
||||||
|
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
|
||||||
|
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
|
||||||
|
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
|
||||||
|
|
||||||
|
The data folder (DB, cache) is always the OS app-data directory
|
||||||
|
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
|
||||||
|
|
||||||
|
> Note: if a Navidrome server is already running on the configured host/port,
|
||||||
|
> the app simply loads it and does **not** start a second instance.
|
||||||
44
desktop/scripts/build.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the Navidrome sidecar binary and package the Quintodrome desktop app.
|
||||||
|
#
|
||||||
|
# Usage: ./desktop/scripts/build.sh [--skip-tauri]
|
||||||
|
#
|
||||||
|
# The script:
|
||||||
|
# 1. Builds the Navidrome server for the host platform (via `make build`).
|
||||||
|
# 2. Copies it into src-tauri/binaries/ under the target-triple name that
|
||||||
|
# tauri-plugin-shell expects for a sidecar (e.g. navidrome-aarch64-apple-darwin).
|
||||||
|
# 3. Builds the Tauri application (unless --skip-tauri is passed).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DESKTOP="$ROOT/desktop"
|
||||||
|
SRC_TAURI="$DESKTOP/src-tauri"
|
||||||
|
|
||||||
|
SKIP_TAURI=false
|
||||||
|
if [[ "${1:-}" == "--skip-tauri" ]]; then
|
||||||
|
SKIP_TAURI=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building Navidrome server binary..."
|
||||||
|
(cd "$ROOT" && make build)
|
||||||
|
|
||||||
|
TRIPLE="$(rustc -vV | sed -n 's/^host: //p')"
|
||||||
|
BIN_NAME="navidrome-$TRIPLE"
|
||||||
|
case "$TRIPLE" in
|
||||||
|
*windows*) BIN_NAME="$BIN_NAME.exe" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> Staging sidecar as binaries/$BIN_NAME"
|
||||||
|
mkdir -p "$SRC_TAURI/binaries"
|
||||||
|
cp "$ROOT/navidrome" "$SRC_TAURI/binaries/$BIN_NAME"
|
||||||
|
chmod +x "$SRC_TAURI/binaries/$BIN_NAME"
|
||||||
|
|
||||||
|
if [[ "$SKIP_TAURI" == true ]]; then
|
||||||
|
echo "==> Skipping Tauri build (--skip-tauri)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building Quintodrome Tauri app..."
|
||||||
|
(cd "$DESKTOP" && npx tauri build)
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
103
desktop/scripts/gen_icon.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// gen_icon generates a 1024x1024 source icon for the Quintodrome app.
|
||||||
|
// Run: go run desktop/scripts/gen_icon.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const size = 1024
|
||||||
|
const ss = 4 // supersampling factor per axis
|
||||||
|
|
||||||
|
func lerp(a, b, t float64) float64 { return a + (b-a)*t }
|
||||||
|
|
||||||
|
type rgb struct{ r, g, b float64 }
|
||||||
|
|
||||||
|
func mix(a, b rgb, t float64) rgb {
|
||||||
|
return rgb{lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
bgTop = rgb{58, 42, 94}
|
||||||
|
bgBottom = rgb{23, 21, 28}
|
||||||
|
circleTop = rgb{168, 85, 247}
|
||||||
|
circleBottom = rgb{124, 58, 237}
|
||||||
|
white = rgb{255, 255, 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
func inTriangle(px, py, ax, ay, bx, by, cx, cy float64) bool {
|
||||||
|
sign := func(x1, y1, x2, y2, x3, y3 float64) float64 {
|
||||||
|
return (x1-x3)*(y2-y3) - (x2-x3)*(y1-y3)
|
||||||
|
}
|
||||||
|
d1 := sign(px, py, ax, ay, bx, by)
|
||||||
|
d2 := sign(px, py, bx, by, cx, cy)
|
||||||
|
d3 := sign(px, py, cx, cy, ax, ay)
|
||||||
|
neg := d1 < 0 || d2 < 0 || d3 < 0
|
||||||
|
pos := d1 > 0 || d2 > 0 || d3 > 0
|
||||||
|
return !(neg && pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sample(x, y float64) rgb {
|
||||||
|
// background vertical gradient
|
||||||
|
c := mix(bgTop, bgBottom, y/size)
|
||||||
|
|
||||||
|
// centered circle
|
||||||
|
cx, cy, cr := float64(size)/2, float64(size)/2, float64(size)*0.31
|
||||||
|
dx, dy := x-cx, y-cy
|
||||||
|
if d := dx*dx + dy*dy; d <= cr*cr {
|
||||||
|
t := (y - (cy - cr)) / (2 * cr)
|
||||||
|
circle := mix(circleTop, circleBottom, t)
|
||||||
|
c = circle
|
||||||
|
}
|
||||||
|
|
||||||
|
// play triangle
|
||||||
|
tx := float64(size)*0.512
|
||||||
|
ax, ay := tx-float64(size)*0.145, float64(size)*0.342
|
||||||
|
bx, by := tx-float64(size)*0.145, float64(size)*0.658
|
||||||
|
cxv, cyv := tx+float64(size)*0.165, float64(size)*0.5
|
||||||
|
if inTriangle(x, y, ax, ay, bx, by, cxv, cyv) {
|
||||||
|
c = white
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
for y := 0; y < size; y++ {
|
||||||
|
for x := 0; x < size; x++ {
|
||||||
|
var acc rgb
|
||||||
|
for sy := 0; sy < ss; sy++ {
|
||||||
|
for sx := 0; sx < ss; sx++ {
|
||||||
|
fx := float64(x) + (float64(sx)+0.5)/ss
|
||||||
|
fy := float64(y) + (float64(sy)+0.5)/ss
|
||||||
|
s := sample(fx, fy)
|
||||||
|
acc.r += s.r
|
||||||
|
acc.g += s.g
|
||||||
|
acc.b += s.b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := float64(ss * ss)
|
||||||
|
img.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(acc.r / n),
|
||||||
|
G: uint8(acc.g / n),
|
||||||
|
B: uint8(acc.b / n),
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(os.Args[len(os.Args)-1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := png.Encode(f, img); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
desktop/src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
binaries/*
|
||||||
|
!binaries/.gitkeep
|
||||||
|
target/
|
||||||
|
gen/
|
||||||
4709
desktop/src-tauri/Cargo.lock
generated
Normal file
27
desktop/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "quintodrome"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Desktop wrapper that hosts Navidrome"
|
||||||
|
authors = ["Quintodrome"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "quintodrome_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# This feature is used for production builds or when `devPath` points to the
|
||||||
|
# filesystem and the built-in dev server is disabled.
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
desktop/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
7
desktop/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the Quintodrome desktop window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
BIN
desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
desktop/src-tauri/icons/icon.icns
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
55
desktop/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
use server::{Navidrome, ServerState};
|
||||||
|
use tauri::{Manager, RunEvent};
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(ServerState::default())
|
||||||
|
.setup(|app| {
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
|
||||||
|
let host = std::env::var("QUINTODROME_HOST")
|
||||||
|
.unwrap_or_else(|_| server::DEFAULT_HOST.to_string());
|
||||||
|
let port = std::env::var("QUINTODROME_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse::<u16>().ok())
|
||||||
|
.unwrap_or(server::DEFAULT_PORT);
|
||||||
|
|
||||||
|
let navidrome = Navidrome::new(host, port);
|
||||||
|
let url = navidrome.url.clone();
|
||||||
|
|
||||||
|
// Detect/start Navidrome off the main thread, then point the
|
||||||
|
// window at it once it is ready.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(err) = server::ensure_running(&handle, &navidrome) {
|
||||||
|
eprintln!("quintodrome: {err}");
|
||||||
|
let message = serde_json::to_string(&err.to_string()).unwrap();
|
||||||
|
if let Some(window) = handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval(&format!(
|
||||||
|
"document.getElementById('status').hidden = true;\
|
||||||
|
var e = document.getElementById('error');\
|
||||||
|
e.hidden = false;\
|
||||||
|
e.textContent = 'Failed to start Navidrome: ' + {message};"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval(&format!("window.location.replace('{url}')"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building Quintodrome")
|
||||||
|
.run(|app, event| {
|
||||||
|
if let RunEvent::Exit = event {
|
||||||
|
server::shutdown(&app.state::<ServerState>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
5
desktop/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
quintodrome_lib::run();
|
||||||
|
}
|
||||||
227
desktop/src-tauri/src/server.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::{TcpStream, ToSocketAddrs},
|
||||||
|
sync::Mutex,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_shell::{
|
||||||
|
process::{CommandChild, CommandEvent},
|
||||||
|
ShellExt,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub const DEFAULT_HOST: &str = "127.0.0.1";
|
||||||
|
pub const DEFAULT_PORT: u16 = 4533;
|
||||||
|
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
const HEALTH_PATH: &str = "/ping";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ServerError {
|
||||||
|
#[error("could not resolve the application data folder")]
|
||||||
|
DataFolder,
|
||||||
|
#[error("could not resolve the music folder")]
|
||||||
|
MusicFolder,
|
||||||
|
#[error("failed to spawn navidrome: {0}")]
|
||||||
|
Spawn(#[from] tauri_plugin_shell::Error),
|
||||||
|
#[error("navidrome did not become ready within {READY_TIMEOUT:?}")]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the handle to the Navidrome child process so it can be shut down
|
||||||
|
/// gracefully when the app exits. It is `None` when Navidrome was already
|
||||||
|
/// running before the app started.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ServerState {
|
||||||
|
child: Mutex<Option<CommandChild>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Navidrome {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Navidrome {
|
||||||
|
pub fn new(host: String, port: u16) -> Self {
|
||||||
|
let url = format!("http://{host}:{port}");
|
||||||
|
Self { host, port, url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures a Navidrome server is reachable at `navidrome.url`. If one is
|
||||||
|
/// already listening, it is reused as-is. Otherwise a bundled Navidrome binary
|
||||||
|
/// is spawned as a separate child process and we wait for it to become ready.
|
||||||
|
pub fn ensure_running(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
|
||||||
|
if is_ready(&navidrome.host, navidrome.port) {
|
||||||
|
eprintln!(
|
||||||
|
"quintodrome: navidrome is already running at {}",
|
||||||
|
navidrome.url
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("quintodrome: navidrome is not running, starting it...");
|
||||||
|
spawn(handle, navidrome)?;
|
||||||
|
wait_until_ready(&navidrome.host, navidrome.port)?;
|
||||||
|
eprintln!("quintodrome: navidrome is ready at {}", navidrome.url);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
|
||||||
|
let data_folder = handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|_| ServerError::DataFolder)?;
|
||||||
|
std::fs::create_dir_all(&data_folder).ok();
|
||||||
|
|
||||||
|
let music_folder = match std::env::var_os("QUINTODROME_MUSIC_FOLDER") {
|
||||||
|
Some(path) if !path.is_empty() => path.into(),
|
||||||
|
_ => handle
|
||||||
|
.path()
|
||||||
|
.audio_dir()
|
||||||
|
.map_err(|_| ServerError::MusicFolder)?,
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&music_folder).ok();
|
||||||
|
|
||||||
|
let data_folder = data_folder.to_string_lossy().to_string();
|
||||||
|
let music_folder = music_folder.to_string_lossy().to_string();
|
||||||
|
let port = navidrome.port.to_string();
|
||||||
|
|
||||||
|
let args = vec![
|
||||||
|
"--nobanner".to_string(),
|
||||||
|
"--address".to_string(),
|
||||||
|
navidrome.host.clone(),
|
||||||
|
"--port".to_string(),
|
||||||
|
port,
|
||||||
|
"--datafolder".to_string(),
|
||||||
|
data_folder,
|
||||||
|
"--musicfolder".to_string(),
|
||||||
|
music_folder,
|
||||||
|
];
|
||||||
|
|
||||||
|
let (mut rx, child) = handle
|
||||||
|
.shell()
|
||||||
|
.sidecar("navidrome")?
|
||||||
|
.args(args)
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let pid = child.pid();
|
||||||
|
eprintln!("quintodrome: started navidrome (pid {pid})");
|
||||||
|
|
||||||
|
*handle.state::<ServerState>().child.lock().unwrap() = Some(child);
|
||||||
|
|
||||||
|
// Forward Navidrome's output to our own stdout/stderr.
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
CommandEvent::Stdout(line) => {
|
||||||
|
print!("{}", String::from_utf8_lossy(&line));
|
||||||
|
}
|
||||||
|
CommandEvent::Stderr(line) => {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&line));
|
||||||
|
}
|
||||||
|
CommandEvent::Terminated(payload) => {
|
||||||
|
eprintln!(
|
||||||
|
"quintodrome: navidrome exited with code {:?}",
|
||||||
|
payload.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_ready(host: &str, port: u16) -> Result<(), ServerError> {
|
||||||
|
let deadline = Instant::now() + READY_TIMEOUT;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if is_ready(host, port) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
Err(ServerError::Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a lightweight HTTP health check against Navidrome's `/ping`
|
||||||
|
/// endpoint. A successful `200` response means the server is up and serving.
|
||||||
|
fn is_ready(host: &str, port: u16) -> bool {
|
||||||
|
let addr = match (host, port).to_socket_addrs().ok().and_then(|mut it| it.next()) {
|
||||||
|
Some(addr) => addr,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
|
||||||
|
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
|
||||||
|
|
||||||
|
let request = format!(
|
||||||
|
"GET {HEALTH_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
if stream.write_all(request.as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = Vec::new();
|
||||||
|
let mut buffer = [0u8; 512];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => response.extend_from_slice(&buffer[..n]),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = String::from_utf8_lossy(&response);
|
||||||
|
response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends SIGTERM to the spawned Navidrome process (so it can shut down
|
||||||
|
/// gracefully) and falls back to a hard kill if it does not exit in time.
|
||||||
|
pub fn shutdown(state: &ServerState) {
|
||||||
|
let child = match state.child.lock().unwrap().take() {
|
||||||
|
Some(child) => child,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = child.pid();
|
||||||
|
eprintln!("quintodrome: stopping navidrome (pid {pid})");
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + GRACEFUL_SHUTDOWN_TIMEOUT;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if !process_alive(pid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = child.kill() {
|
||||||
|
eprintln!("quintodrome: failed to kill navidrome: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn process_alive(pid: u32) -> bool {
|
||||||
|
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn process_alive(_pid: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
40
desktop/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Quintodrome",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.quintodrome.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../src"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Quintodrome",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"center": true,
|
||||||
|
"url": "index.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": ["binaries/navidrome"]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
desktop/src/index.html
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quintodrome</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1b1b1f;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-top-color: #a855f7;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
#error {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Quintodrome</h1>
|
||||||
|
<div class="spinner" id="spinner"></div>
|
||||||
|
<p id="status">Starting Navidrome…</p>
|
||||||
|
<p id="error" hidden></p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const error = params.get("error");
|
||||||
|
if (error) {
|
||||||
|
document.getElementById("spinner").hidden = true;
|
||||||
|
document.getElementById("status").hidden = true;
|
||||||
|
const el = document.getElementById("error");
|
||||||
|
el.hidden = false;
|
||||||
|
el.textContent = "Failed to start Navidrome: " + error;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||