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