//! Loopback HTTP server for theme assets on Linux and macOS. //! //! WebKitGTK cannot play audio or video through Tauri's custom `asset:` //! protocol, and WKWebView's range handling makes large media stall there. //! A real loopback HTTP server gives WebKit normal status codes, range //! responses and MIME types, so v1 and v2 theme assets load the same way they //! do in a browser without loading a whole video into a blob first. use http_body_util::Full; use http_range::HttpRange; use hyper::body::{Bytes, Incoming}; use hyper::header::{ ACCEPT_RANGES, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ORIGIN, RANGE, VARY, }; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; use tokio::net::TcpListener; type Body = Full; /// Keep individual responses small enough that a media seek cannot allocate /// an unbounded buffer. WebKit asks for the next range as it plays. const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024; struct ThemeServer { port: u16, root: PathBuf, previews: Arc>>, } static SERVER: OnceLock = OnceLock::new(); /// Bind the server on a random loopback port. The root is the themes folder. pub fn start(root: PathBuf, media_root: PathBuf) -> Result { if let Some(server) = SERVER.get() { return Ok(server.port); } let root = std::fs::canonicalize(&root) .map_err(|error| format!("Could not resolve the themes folder: {error}"))?; let media_root = std::fs::canonicalize(&media_root) .map_err(|error| format!("Could not resolve the theme media cache: {error}"))?; let listener = std::net::TcpListener::bind(("127.0.0.1", 0)) .map_err(|error| format!("Could not start the theme asset server: {error}"))?; listener .set_nonblocking(true) .map_err(|error| format!("Could not configure the theme asset server: {error}"))?; let port = listener .local_addr() .map_err(|error| format!("Could not read the theme asset server address: {error}"))? .port(); let previews = Arc::new(Mutex::new(HashMap::new())); let server = ThemeServer { port, root: root.clone(), previews: previews.clone(), }; let _ = SERVER.set(server); let root = Arc::new(root); let media_root = Arc::new(media_root); tauri::async_runtime::spawn(async move { let listener = match TcpListener::from_std(listener) { Ok(listener) => listener, Err(error) => { log::error!(target: "launcher", "Theme asset server failed: {error}"); return; } }; log::info!( target: "launcher", "Theme asset server listening on http://127.0.0.1:{port}/themes" ); loop { let (stream, _) = match listener.accept().await { Ok(connection) => connection, Err(error) => { log::debug!(target: "launcher", "Theme asset server accept error: {error}"); continue; } }; let root = root.clone(); let media_root = media_root.clone(); let previews = previews.clone(); tokio::spawn(async move { let service = service_fn(move |request| { handle(request, root.clone(), media_root.clone(), previews.clone()) }); if let Err(error) = http1::Builder::new() .serve_connection(TokioIo::new(stream), service) .await { log::debug!(target: "launcher", "Theme asset connection ended: {error}"); } }); } }); Ok(port) } pub fn base_url() -> Option { SERVER .get() .map(|server| format!("http://127.0.0.1:{}/themes", server.port)) } /// The cache folder for optimized theme media, next to the themes folder. pub fn media_root() -> Result { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_err(|_| "Could not locate the home directory".to_string())?; let root = PathBuf::from(home) .join(".ezpplauncher") .join("media-cache"); std::fs::create_dir_all(&root) .map_err(|error| format!("Failed to create the theme media cache: {error}"))?; std::fs::canonicalize(&root) .map_err(|error| format!("Failed to resolve the theme media cache: {error}")) } /// Map a loopback theme URL back to the file it serves. Installed theme files /// are restricted to the themes folder; editor preview URLs must match a token /// registered through `preview_url`. pub fn resolve_theme_url(value: &str) -> Option { let server = SERVER.get()?; let url = url::Url::parse(value).ok()?; if url.scheme() != "http" || url.host_str() != Some("127.0.0.1") || url.port() != Some(server.port) { return None; } let path = percent_encoding::percent_decode_str(url.path()) .decode_utf8_lossy() .to_string(); if let Some(relative) = path.strip_prefix("/themes/") { return safe_path(&server.root, relative); } let token = path.strip_prefix("/preview/")?; server .previews .lock() .ok() .and_then(|previews| previews.get(token).cloned()) } /// Public URL for a file in the optimized media cache. pub fn media_url(file_name: &str) -> Option { let server = SERVER.get()?; Some(format!( "http://127.0.0.1:{}/media/{}", server.port, file_name )) } /// Register a file picked in the theme editor so its preview can be served /// over HTTP too. The token is only a lookup key; unregistered paths are never /// readable through the server. pub fn preview_url(path: &Path) -> Result { let server = SERVER .get() .ok_or_else(|| "The theme asset server is not running".to_string())?; let path = std::fs::canonicalize(path) .map_err(|error| format!("Could not resolve the preview asset: {error}"))?; if !path.is_file() { return Err("The preview asset is not a file".to_string()); } let mut hasher = Sha256::new(); hasher.update(path.to_string_lossy().as_bytes()); hasher.update( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() .to_le_bytes(), ); let token = hex::encode(&hasher.finalize()[..16]); server .previews .lock() .map_err(|_| "Could not register the preview asset".to_string())? .insert(token.clone(), path); Ok(format!("http://127.0.0.1:{}/preview/{token}", server.port)) } async fn handle( request: Request, root: Arc, media_root: Arc, previews: Arc>>, ) -> Result, Infallible> { let origin = request .headers() .get(ORIGIN) .and_then(|value| value.to_str().ok()) .and_then(allowed_origin); if request.method() == Method::OPTIONS { return Ok(preflight_response(origin.as_deref())); } if request.method() != Method::GET && request.method() != Method::HEAD { return Ok(text_response( StatusCode::METHOD_NOT_ALLOWED, "Method not allowed", origin.as_deref(), )); } let range = request .headers() .get(RANGE) .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); let path = percent_encoding::percent_decode_str(request.uri().path()) .decode_utf8_lossy() .to_string(); log::debug!( target: "launcher", "Theme asset request: {path} range={range:?}" ); let file = if let Some(relative) = path.strip_prefix("/themes/") { safe_path(&root, relative) } else if let Some(relative) = path.strip_prefix("/media/") { safe_path(&media_root, relative) } else if let Some(token) = path.strip_prefix("/preview/") { previews .lock() .ok() .and_then(|previews| previews.get(token).cloned()) } else { None }; let response = match file { Some(file) => { serve_file(request.method(), &file, range.as_deref(), origin.as_deref()).await } None => Ok(text_response( StatusCode::NOT_FOUND, "Not found", origin.as_deref(), )), }; Ok(response.unwrap_or_else(|message| { log::warn!(target: "launcher", "Theme asset request failed: {message}"); text_response( StatusCode::INTERNAL_SERVER_ERROR, &message, origin.as_deref(), ) })) } fn safe_path(root: &Path, relative: &str) -> Option { let relative = relative.trim_start_matches('/'); if relative.is_empty() || relative .split('/') .any(|part| part.is_empty() || part == "." || part == "..") { return None; } let candidate = root.join(relative); let canonical = std::fs::canonicalize(candidate).ok()?; canonical.starts_with(root).then_some(canonical) } async fn serve_file( method: &Method, path: &Path, range_header: Option<&str>, origin: Option<&str>, ) -> Result, String> { let metadata = tokio::fs::metadata(path) .await .map_err(|error| format!("Could not read theme asset: {error}"))?; if !metadata.is_file() { return Ok(text_response(StatusCode::NOT_FOUND, "Not found", origin)); } let length = metadata.len(); let range = match range_header { Some(header) => match parse_range(header, length) { Ok(range) => range, Err(()) => return Ok(range_not_satisfiable(length, origin)), }, None => None, }; let (status, start, end) = match range { Some((start, end)) => (StatusCode::PARTIAL_CONTENT, start, end), None => (StatusCode::OK, 0, length.saturating_sub(1)), }; let body_length = if length == 0 { 0 } else { end.saturating_sub(start) + 1 }; let body = if method == Method::HEAD || body_length == 0 { Vec::new() } else { let mut file = tokio::fs::File::open(path) .await .map_err(|error| format!("Could not open theme asset: {error}"))?; if start > 0 { file.seek(SeekFrom::Start(start)) .await .map_err(|error| format!("Could not seek theme asset: {error}"))?; } let mut buffer = vec![0_u8; body_length as usize]; file.read_exact(&mut buffer) .await .map_err(|error| format!("Could not read theme asset: {error}"))?; buffer }; let mut builder = Response::builder() .status(status) .header(CONTENT_TYPE, content_type(path)) .header(CONTENT_LENGTH, body_length.to_string()) .header(ACCEPT_RANGES, "bytes") .header("Cache-Control", "no-store") .header("X-Content-Type-Options", "nosniff") .header("Cross-Origin-Resource-Policy", "cross-origin"); if status == StatusCode::PARTIAL_CONTENT { builder = builder.header(CONTENT_RANGE, format!("bytes {start}-{end}/{length}")); } if let Some(origin) = origin { builder = builder .header(ACCESS_CONTROL_ALLOW_ORIGIN, origin) .header(VARY, "Origin") .header( ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Range, Content-Length, Accept-Ranges", ); } builder .body(Full::new(body.into())) .map_err(|error| format!("Could not build theme asset response: {error}")) } fn parse_range(header: &str, length: u64) -> Result, ()> { if length == 0 { return Ok(None); } let ranges = HttpRange::parse(header, length).map_err(|_| ())?; if ranges.len() != 1 { return Err(()); } let range = &ranges[0]; let start = range.start; let end = (start + range.length - 1).min(length - 1); if start >= length || end < start { return Err(()); } // Don't allocate the entire file for a single open-ended range request. Ok(Some((start, end.min(start + MAX_RANGE_BYTES - 1)))) } fn range_not_satisfiable(length: u64, origin: Option<&str>) -> Response { let mut builder = Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(CONTENT_RANGE, format!("bytes */{length}")) .header(CONTENT_TYPE, "text/plain; charset=utf-8") .header(CONTENT_LENGTH, "0"); if let Some(origin) = origin { builder = builder .header(ACCESS_CONTROL_ALLOW_ORIGIN, origin) .header(VARY, "Origin"); } builder .body(Full::new(Bytes::new())) .expect("range response is valid") } fn preflight_response(origin: Option<&str>) -> Response { let mut builder = Response::builder() .status(StatusCode::NO_CONTENT) .header(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS") .header(ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type") .header( ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Range, Content-Length, Accept-Ranges", ) .header(ACCESS_CONTROL_MAX_AGE, "600"); if let Some(origin) = origin { builder = builder .header(ACCESS_CONTROL_ALLOW_ORIGIN, origin) .header(VARY, "Origin"); } builder .body(Full::new(Bytes::new())) .expect("preflight response is valid") } fn text_response(status: StatusCode, message: &str, origin: Option<&str>) -> Response { let mut builder = Response::builder() .status(status) .header(CONTENT_TYPE, "text/plain; charset=utf-8") .header(CONTENT_LENGTH, message.len().to_string()) .header("X-Content-Type-Options", "nosniff"); if let Some(origin) = origin { builder = builder .header(ACCESS_CONTROL_ALLOW_ORIGIN, origin) .header(VARY, "Origin"); } builder .body(Full::new(Bytes::copy_from_slice(message.as_bytes()))) .expect("text response is valid") } fn allowed_origin(origin: &str) -> Option { if origin == "tauri://localhost" || origin == "http://tauri.localhost" || origin == "https://tauri.localhost" || origin == "null" || origin.starts_with("http://localhost:") || origin.starts_with("http://127.0.0.1:") { Some(origin.to_string()) } else { None } } fn content_type(path: &Path) -> &'static str { match path .extension() .and_then(|extension| extension.to_str()) .unwrap_or_default() .to_ascii_lowercase() .as_str() { "html" | "htm" => "text/html; charset=utf-8", "js" | "mjs" => "text/javascript; charset=utf-8", "css" => "text/css; charset=utf-8", "json" => "application/json; charset=utf-8", "svg" => "image/svg+xml", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "avif" => "image/avif", "ico" => "image/x-icon", "webm" => "video/webm", "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "ogv" => "video/ogg", "mp3" => "audio/mpeg", "ogg" | "oga" => "audio/ogg", "wav" => "audio/wav", "m4a" => "audio/mp4", "flac" => "audio/flac", "woff2" => "font/woff2", "woff" => "font/woff", "ttf" => "font/ttf", "otf" => "font/otf", "wasm" => "application/wasm", _ => "application/octet-stream", } }