503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
//! 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<Bytes>;
|
|
|
|
/// 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<Mutex<HashMap<String, PathBuf>>>,
|
|
}
|
|
|
|
static SERVER: OnceLock<ThemeServer> = 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<u16, String> {
|
|
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<String> {
|
|
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<PathBuf, String> {
|
|
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<PathBuf> {
|
|
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<String> {
|
|
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<String, String> {
|
|
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<Incoming>,
|
|
root: Arc<PathBuf>,
|
|
media_root: Arc<PathBuf>,
|
|
previews: Arc<Mutex<HashMap<String, PathBuf>>>,
|
|
) -> Result<Response<Body>, 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<PathBuf> {
|
|
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<Response<Body>, 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<Option<(u64, u64)>, ()> {
|
|
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<Body> {
|
|
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<Body> {
|
|
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<Body> {
|
|
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<String> {
|
|
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",
|
|
}
|
|
}
|