quintodrome/desktop/src-tauri/src/lib.rs
2026-08-25 03:37:40 -08:00

245 lines
8.8 KiB
Rust

mod server;
use server::{Navidrome, ServerState};
use tauri::{
Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, WebviewBuilder, WebviewUrl,
WindowEvent,
};
use tauri_plugin_opener::OpenerExt;
const TOOLBAR_HEIGHT: f64 = 60.0;
const DEFAULT_PUBLIC_URL: &str = "http://quintodrome";
/// Maps the internal Navidrome origin (e.g. `http://127.0.0.1:4533`) to a
/// friendlier, user-facing origin shown in the address bar.
#[derive(Clone)]
struct UrlMask {
real: String,
public: String,
}
impl UrlMask {
fn new(host: &str, port: u16) -> Self {
let real = format!("http://{host}:{port}");
let public = std::env::var("QUINTODROME_PUBLIC_URL")
.unwrap_or_else(|_| DEFAULT_PUBLIC_URL.to_string());
Self { real, public }
}
/// `http://127.0.0.1:4533/...` -> `http://quintodrome/...`
fn mask(&self, url: &str) -> String {
url.strip_prefix(&self.real)
.map(|rest| format!("{}{}", self.public, rest))
.unwrap_or_else(|| url.to_string())
}
/// `http://quintodrome/...` -> `http://127.0.0.1:4533/...`
fn unmask(&self, url: &str) -> String {
url.strip_prefix(&self.public)
.map(|rest| format!("{}{}", self.real, rest))
.unwrap_or_else(|| url.to_string())
}
}
#[tauri::command]
fn back(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.eval("history.back()");
}
}
#[tauri::command]
fn forward(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.eval("history.forward()");
}
}
#[tauri::command]
fn reload(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.reload();
}
}
#[tauri::command]
fn navigate(app: tauri::AppHandle, url: String) {
let url = app.state::<UrlMask>().unmask(&url);
if let Some(content) = app.get_webview("content") {
if let Ok(url) = Url::parse(&url) {
let _ = content.navigate(url);
}
}
}
/// Enables two-finger swipe-to-navigate (back/forward) in the content webview.
#[cfg(target_os = "macos")]
fn enable_swipe_navigation(app: &tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.with_webview(|webview| unsafe {
let view: &objc2_web_kit::WKWebView = &*webview.inner().cast();
view.setAllowsBackForwardNavigationGestures(true);
});
}
}
/// Keeps the address bar in sync with the content webview's real URL.
///
/// `on_navigation` only fires for actual document loads, so history traversal
/// (back/forward) and SPA `pushState` navigation would otherwise leave the
/// address bar stale. Polling `Webview::url()` covers all of those.
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
let handle = app.clone();
std::thread::spawn(move || {
let mut last: Option<String> = None;
loop {
std::thread::sleep(std::time::Duration::from_millis(400));
let Some(content) = handle.get_webview("content") else {
continue;
};
let Ok(url) = content.url() else { continue };
if url.scheme() != "http" && url.scheme() != "https" {
continue;
}
if url.host_str() == Some("tauri.localhost") {
continue;
}
let masked = mask.mask(url.as_str());
if last.as_deref() != Some(masked.as_str()) {
if let Some(toolbar) = handle.get_webview("main") {
let _ = toolbar.emit("url-changed", masked.clone());
}
last = Some(masked);
}
}
});
}
/// Positions the toolbar strip at the top and the content webview below it.
fn layout(app: &tauri::AppHandle) {
let Some(window) = app.get_window("main") else {
return;
};
let Ok(size) = window.inner_size() else {
return;
};
let Ok(scale) = window.scale_factor() else {
return;
};
let width = size.width as f64 / scale;
let height = size.height as f64 / scale;
if let Some(toolbar) = app.get_webview("main") {
let _ = toolbar.set_auto_resize(false);
let _ = toolbar.set_position(LogicalPosition::new(0.0, 0.0));
let _ = toolbar.set_size(LogicalSize::new(width, TOOLBAR_HEIGHT));
}
if let Some(content) = app.get_webview("content") {
let _ = content.set_auto_resize(false);
let _ = content.set_position(LogicalPosition::new(0.0, TOOLBAR_HEIGHT));
let _ = content.set_size(LogicalSize::new(width, (height - TOOLBAR_HEIGHT).max(0.0)));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.manage(ServerState::default())
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
.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.clone(), port);
let navidrome_url = navidrome.url.clone();
let mask = UrlMask::new(&host, port);
app.manage(mask.clone());
// The primary webview ("main") is the toolbar; the Navidrome content
// lives in a child webview below it.
let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into()))
.on_new_window({
let handle = handle.clone();
move |url, _features| {
// Open external links (Last.fm, etc.) in the default
// browser instead of a new in-app window.
if url.scheme() == "http" || url.scheme() == "https" {
let _ = handle.opener().open_url(url.to_string(), None::<&str>);
}
tauri::webview::NewWindowResponse::Deny
}
});
if let Some(window) = app.get_window("main") {
let _ = window.add_child(
content,
LogicalPosition::new(0.0, TOOLBAR_HEIGHT),
LogicalSize::new(800.0, 600.0),
);
let handle = handle.clone();
window.on_window_event(move |event| {
if let WindowEvent::Resized(_) = event {
layout(&handle);
}
});
}
layout(app.handle());
#[cfg(target_os = "macos")]
enable_swipe_navigation(app.handle());
if let Some(toolbar) = app.get_webview("main") {
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
}
start_url_poller(app.handle(), mask);
// Detect/start Navidrome off the main thread, then load it.
let content_url = navidrome_url.clone();
std::thread::spawn(move || {
if let Err(err) = server::ensure_running(&handle, &navidrome) {
eprintln!("quintodrome: {err}");
if let Some(content) = handle.get_webview("content") {
let message = serde_json::to_string(&err.to_string()).unwrap();
let _ = content.eval(&format!(
"document.getElementById('spinner').hidden = true;\
document.getElementById('status').hidden = true;\
var e = document.getElementById('error');\
e.hidden = false;\
e.textContent = 'Failed to start Navidrome: ' + {message};"
));
}
return;
}
// Replace (not push) so the splash page isn't left in history —
// otherwise "back" would return to the loading screen.
if let Some(content) = handle.get_webview("content") {
let js = format!(
"location.replace({})",
serde_json::to_string(&content_url).unwrap()
);
let _ = content.eval(&js);
}
});
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building Quintodrome")
.run(|app, event| {
if let RunEvent::Exit = event {
server::shutdown(&app.state::<ServerState>());
}
});
}