desktop app

This commit is contained in:
Philip Peterson 2026-08-24 20:49:04 -08:00
parent 3b958dd6a7
commit 86ed1bbb1e
30 changed files with 5421 additions and 0 deletions

112
desktop/README.md Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
binaries/*
!binaries/.gitkeep
target/
gen/

4709
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View 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"]

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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>());
}
});
}

View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
quintodrome_lib::run();
}

View 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
}

View 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
View 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>